initial commit for beadboard
This commit is contained in:
parent
93f3c50d4b
commit
54729c72f6
14 changed files with 1472 additions and 2108 deletions
297
src/components/activity/activity-panel.tsx
Normal file
297
src/components/activity/activity-panel.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ActivityEvent } from '../../lib/activity';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead';
|
||||
|
||||
interface AgentRosterEntry {
|
||||
name: string;
|
||||
status: AgentStatus;
|
||||
lastSeen: string | null;
|
||||
beadId: string;
|
||||
}
|
||||
|
||||
interface ActivityPanelProps {
|
||||
issues: BeadIssue[];
|
||||
}
|
||||
|
||||
const AGENT_LABEL = 'gt:agent';
|
||||
|
||||
// Determine agent status based on last activity
|
||||
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
|
||||
if (!lastSeenAt) return 'dead';
|
||||
|
||||
const lastSeen = new Date(lastSeenAt);
|
||||
const now = new Date();
|
||||
const minutesSince = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
|
||||
|
||||
if (minutesSince < 15) return 'active';
|
||||
if (minutesSince < 30) return 'stale';
|
||||
if (minutesSince < 60) return 'stuck';
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
// Get agent name from bead
|
||||
function extractAgentName(issue: BeadIssue): string | null {
|
||||
const agentMatch = issue.title.match(/Agent:\s*(\S+)/i);
|
||||
if (agentMatch) return agentMatch[1];
|
||||
|
||||
const agentLabel = issue.labels.find(l => l.startsWith('agent:'));
|
||||
if (agentLabel) return agentLabel.replace('agent:', '');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build agent roster - filter out dead agents unless none are active
|
||||
function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
|
||||
const agentIssues = issues.filter(issue =>
|
||||
issue.labels.includes(AGENT_LABEL) || issue.labels.some(l => l.startsWith('gt:agent'))
|
||||
);
|
||||
|
||||
const roster = agentIssues.map(issue => {
|
||||
const name = extractAgentName(issue) || issue.id;
|
||||
const status = deriveAgentStatus(issue.updated_at);
|
||||
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
lastSeen: issue.updated_at,
|
||||
beadId: issue.id,
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
const statusOrder: Record<AgentStatus, number> = { active: 0, stale: 1, stuck: 2, dead: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status];
|
||||
});
|
||||
|
||||
// Filter: if there are active agents, show only active + stale (max 5)
|
||||
// If no active, show stale + stuck (max 3)
|
||||
// Dead agents never show unless it's the only thing
|
||||
const activeCount = roster.filter(a => a.status === 'active').length;
|
||||
|
||||
if (activeCount > 0) {
|
||||
return roster.filter(a => a.status !== 'dead').slice(0, 5);
|
||||
} else {
|
||||
return roster.filter(a => a.status !== 'dead').slice(0, 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// Get status badge variant
|
||||
function getStatusVariant(status: AgentStatus): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
switch (status) {
|
||||
case 'active': return 'default';
|
||||
case 'stale': return 'secondary';
|
||||
case 'stuck': return 'outline';
|
||||
case 'dead': return 'destructive';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
// Get event kind icon/color
|
||||
function getEventKindInfo(kind: string): { label: string; color: string } {
|
||||
const events: Record<string, { label: string; color: string }> = {
|
||||
created: { label: 'Created', color: 'text-emerald-500' },
|
||||
closed: { label: 'Closed', color: 'text-amber-500' },
|
||||
reopened: { label: 'Reopened', color: 'text-blue-500' },
|
||||
status_changed: { label: 'Status changed', color: 'text-cyan-500' },
|
||||
priority_changed: { label: 'Priority changed', color: 'text-purple-500' },
|
||||
assignee_changed: { label: 'Assigned', color: 'text-indigo-500' },
|
||||
heartbeat: { label: 'Heartbeat', color: 'text-muted-foreground' },
|
||||
dependency_added: { label: 'Dependency added', color: 'text-orange-500' },
|
||||
dependency_removed: { label: 'Dependency removed', color: 'text-red-500' },
|
||||
};
|
||||
|
||||
return events[kind] || { label: kind.replace(/_/g, ' '), color: 'text-muted-foreground' };
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
export function ActivityPanel({ issues }: ActivityPanelProps) {
|
||||
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
||||
|
||||
// Fetch activity history
|
||||
useEffect(() => {
|
||||
async function fetchActivity() {
|
||||
try {
|
||||
const response = await fetch('/api/activity');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setActivities(data.slice(0, 50)); // Limit to 50 events
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ActivityPanel] Failed to fetch activity:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchActivity();
|
||||
}, []);
|
||||
|
||||
// Subscribe to real-time activity
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/events');
|
||||
|
||||
const onActivity = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.event) {
|
||||
setActivities(prev => [data.event, ...prev].slice(0, 50));
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
source.addEventListener('activity', onActivity as EventListener);
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('activity', onActivity as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
||||
const staleAgents = agentRoster.filter(a => a.status === 'stale').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* AGENT ROSTER SECTION */}
|
||||
<div className="flex-shrink-0 p-3 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold">Agents</h3>
|
||||
<div className="flex gap-2">
|
||||
{activeAgents > 0 && (
|
||||
<Badge variant="default" className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
|
||||
{activeAgents} active
|
||||
</Badge>
|
||||
)}
|
||||
{staleAgents > 0 && (
|
||||
<Badge variant="secondary" className="bg-amber-500/20 text-amber-400 border-amber-500/30">
|
||||
{staleAgents} stale
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agentRoster.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No active agents</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agentRoster.map(agent => (
|
||||
<div
|
||||
key={agent.beadId}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(agent.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{agent.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground capitalize">
|
||||
{agent.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ACTIVITY FEED SECTION */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="p-3 border-b border-border">
|
||||
<h3 className="text-sm font-semibold">Recent Activity</h3>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100%-40px)]">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No recent activity
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{activities.map((activity, index) => {
|
||||
const eventInfo = getEventKindInfo(activity.kind);
|
||||
return (
|
||||
<div key={activity.id}>
|
||||
<div className="flex items-start gap-2 py-2 px-1 rounded hover:bg-muted/50 transition-colors">
|
||||
<div className="flex flex-col items-center mt-0.5">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
activity.kind === 'heartbeat' ? 'bg-muted' :
|
||||
activity.kind === 'created' ? 'bg-emerald-500' :
|
||||
activity.kind === 'closed' ? 'bg-amber-500' :
|
||||
'bg-cyan-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-medium ${eventInfo.color}`}>
|
||||
{eventInfo.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{activity.beadId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground line-clamp-1 mt-0.5">
|
||||
{activity.beadTitle}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{activity.actor && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{activity.actor}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelativeTime(activity.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{index < activities.length - 1 && (
|
||||
<Separator className="my-0.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,91 +43,80 @@ export function ThreadDrawer({ isOpen, onClose, title, id, items = SAMPLE_ITEMS
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="h-full w-[24rem] overflow-hidden flex flex-col"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-card)',
|
||||
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer - slides from right edge of screen, overlaying everything */}
|
||||
<div
|
||||
className="fixed top-0 right-0 h-full z-50 w-[24rem] overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-card)',
|
||||
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
className="flex items-center justify-between p-4 border-b"
|
||||
style={{ borderColor: 'rgba(255, 255, 255, 0.1)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 border-b"
|
||||
style={{ borderColor: 'rgba(255, 255, 255, 0.1)' }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<span className="text-teal-400 font-mono text-sm">
|
||||
{id}
|
||||
</span>
|
||||
<h2
|
||||
className="text-sm font-semibold truncate"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-white/10 transition-colors flex-shrink-0"
|
||||
aria-label="Close"
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<span className="text-teal-400 font-mono text-sm">
|
||||
{id}
|
||||
</span>
|
||||
<h2
|
||||
className="text-sm font-semibold truncate"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
title={title}
|
||||
>
|
||||
<X size={18} style={{ color: 'var(--color-text-muted)' }} />
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-white/10 transition-colors flex-shrink-0"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} style={{ color: 'var(--color-text-muted)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thread Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ThreadView items={items} />
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<div
|
||||
className="p-4 border-t"
|
||||
style={{ borderColor: 'rgba(255, 255, 255, 0.1)' }}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
className="flex-1 px-3 py-2 rounded-md text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-input)',
|
||||
color: 'var(--color-text-primary)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && comment.trim()) {
|
||||
setComment('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="p-2 rounded-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-accent-green)',
|
||||
color: '#fff',
|
||||
}}
|
||||
aria-label="Send comment"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thread Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ height: 'calc(100% - 8rem)' }}>
|
||||
<ThreadView items={items} />
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<div
|
||||
className="p-4 border-t"
|
||||
style={{ borderColor: 'rgba(255, 255, 255, 0.1)' }}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
className="flex-1 px-3 py-2 rounded-md text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-input)',
|
||||
color: 'var(--color-text-primary)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && comment.trim()) {
|
||||
// TODO: Post comment
|
||||
setComment('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="p-2 rounded-md"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-accent-green)',
|
||||
color: '#fff',
|
||||
}}
|
||||
aria-label="Send comment"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { SocialPage } from '../social/social-page';
|
|||
import { SwarmPage } from '../swarm/swarm-page';
|
||||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { buildSwarmCards } from '../../lib/swarm-cards';
|
||||
import { ActivityPanel } from '../activity/activity-panel';
|
||||
|
||||
export interface UnifiedShellProps {
|
||||
issues: BeadIssue[];
|
||||
|
|
@ -57,14 +58,7 @@ export function UnifiedShell({
|
|||
const drawerId = taskId || swarmId || '';
|
||||
|
||||
const renderRightPanel = () => {
|
||||
// TODO: Wire up ActivityPanel (bb-ui2.29) - for now show placeholder
|
||||
return (
|
||||
<div className="p-4 text-center text-text-muted text-sm">
|
||||
Activity Panel coming
|
||||
<br />
|
||||
<span className="text-xs">(bb-ui2.29)</span>
|
||||
</div>
|
||||
);
|
||||
return <ActivityPanel issues={issues} />;
|
||||
};
|
||||
|
||||
const renderMiddleContent = () => {
|
||||
|
|
@ -118,9 +112,21 @@ export function UnifiedShell({
|
|||
{/* LEFT PANEL: 13rem channel tree */}
|
||||
<LeftPanel issues={issues} />
|
||||
|
||||
{/* MIDDLE CONTENT: flex-1 */}
|
||||
<div className="overflow-y-auto" data-testid="middle-content">
|
||||
{/* MIDDLE CONTENT: flex-1 - contains card grid AND thread drawer */}
|
||||
<div className="relative overflow-hidden" data-testid="middle-content">
|
||||
{renderMiddleContent()}
|
||||
|
||||
{/* THREAD DRAWER: Inside middle section, attached to right edge */}
|
||||
{isDrawerOpen && (
|
||||
<div className="absolute top-0 right-0 h-full z-50">
|
||||
<ThreadDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
title={drawerTitle}
|
||||
id={drawerId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL: 17rem - Always shows Activity (bb-ui2.29) */}
|
||||
|
|
@ -129,13 +135,7 @@ export function UnifiedShell({
|
|||
</RightPanel>
|
||||
</div>
|
||||
|
||||
{/* THREAD DRAWER: 24rem - Slides from right edge of middle when card selected */}
|
||||
<ThreadDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
title={drawerTitle}
|
||||
id={drawerId}
|
||||
/>
|
||||
|
||||
|
||||
{/* MOBILE NAV: Bottom tab bar */}
|
||||
<MobileNav />
|
||||
|
|
|
|||
|
|
@ -14,12 +14,16 @@ interface SocialCardProps {
|
|||
}
|
||||
|
||||
const RELATIONSHIP_COLORS = {
|
||||
unlocks: 'text-emerald-400',
|
||||
// NEW: unlocks = what blocks ME (rose)
|
||||
unlocks: 'text-rose-400',
|
||||
// NEW: blocks = what I block (amber)
|
||||
blocks: 'text-amber-400',
|
||||
};
|
||||
|
||||
const DOT_COLORS = {
|
||||
unlocks: 'bg-emerald-400',
|
||||
// NEW: unlocks = what blocks ME (rose)
|
||||
unlocks: 'bg-rose-400',
|
||||
// NEW: blocks = what I block (amber)
|
||||
blocks: 'bg-amber-400',
|
||||
};
|
||||
|
||||
|
|
@ -146,8 +150,9 @@ export function SocialCard({
|
|||
onJumpToGraph,
|
||||
onJumpToKanban,
|
||||
}: SocialCardProps) {
|
||||
const hasUnlocks = data.unlocks.length > 0;
|
||||
// NEW semantic: blocks = what I block (amber), unblocks = what blocks me (rose)
|
||||
const hasBlocks = data.blocks.length > 0;
|
||||
const hasUnblocks = data.unblocks.length > 0;
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
|
|
@ -173,13 +178,16 @@ export function SocialCard({
|
|||
{data.title}
|
||||
</h3>
|
||||
|
||||
{(hasUnlocks || hasBlocks) && (
|
||||
{(hasBlocks || hasUnblocks) && (
|
||||
<div className="space-y-1">
|
||||
<RelationshipSection label="UNLOCKS" items={data.unlocks} color="unlocks" />
|
||||
{/* UNLOCKS: tasks blocking THIS task (rose) - what blocks me */}
|
||||
<RelationshipSection label="UNLOCKS" items={data.unblocks} color="unlocks" />
|
||||
{/* BLOCKS: tasks THIS task blocks (amber) - what I block */}
|
||||
<RelationshipSection label="BLOCKS" items={data.blocks} color="blocks" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{data.agents.slice(0, 3).map((agent) => (
|
||||
|
|
|
|||
|
|
@ -46,10 +46,11 @@ export function SocialDetail({ data }: SocialDetailProps) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* NEW semantic: blocks = what I block (amber), unblocks = what blocks me (rose) */}
|
||||
{data.blocks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-amber-400 text-xs font-semibold uppercase tracking-wider">
|
||||
Blocks
|
||||
Blocks (what I block)
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{data.blocks.map((id) => (
|
||||
|
|
@ -61,13 +62,13 @@ export function SocialDetail({ data }: SocialDetailProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{data.unlocks.length > 0 && (
|
||||
{data.unblocks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-emerald-400 text-xs font-semibold uppercase tracking-wider">
|
||||
Unlocks
|
||||
<h3 className="text-rose-400 text-xs font-semibold uppercase tracking-wider">
|
||||
Unlocks (what blocks me)
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{data.unlocks.map((id) => (
|
||||
{data.unblocks.map((id) => (
|
||||
<li key={id} className="text-text-secondary text-sm font-mono">
|
||||
{id}
|
||||
</li>
|
||||
|
|
@ -76,6 +77,7 @@ export function SocialDetail({ data }: SocialDetailProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-text-muted text-xs font-semibold uppercase tracking-wider">
|
||||
Assigned
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue