feat(ui): add view components for Social, Swarm, and Graph (bb-ui2.11, .16, .20)

STORY:
With the shell layout complete, we needed the actual content for each view.
Three agents worked in parallel on the card components that would populate
the Social and Swarm views, plus integrating the existing graph into the shell.

COLLABORATION:
Agent bb-98c (social-card-builder) created SocialCard:
- Task ID with teal styling
- UNLOCKS section (green) showing what this task unblocks
- BLOCKS section (amber) showing what's blocking this task
- Agent avatars with liveness glow
- View-jump icons for quick navigation

Agent bb-nuy (swarm-card-builder) created SwarmCard:
- Agent roster with liveness indicators
- Progress bar (ASCII block format: ████████░░░░)
- Attention items with warning styling
- View-jump icons

Agent bb-54x (graph-integrator) integrated WorkflowGraph:
- Created GraphView wrapper with Flow/Overview tabs
- Wired into UnifiedShell when view=graph
- Connected taskId to selectedId for URL sync
- Connected graphTab to URL state

DELIVERABLES:
- src/components/social/social-card.tsx: Task card for activity feed
- src/components/swarm/swarm-card.tsx: Swarm health card
- src/components/graph/graph-view.tsx: Graph wrapper with tabs
- src/components/shared/mobile-nav.tsx: Bottom tab bar
- Tests for all components

VERIFICATION:
- npm run typecheck: PASS
- npm run lint: PASS
- npm run test: PASS

CLOSES: bb-ui2.11, bb-ui2.16, bb-ui2.20
This commit is contained in:
zenchantlive 2026-02-15 23:21:20 -08:00
parent ce8fdd0d4c
commit 4efc461c1e
6 changed files with 626 additions and 0 deletions

View file

@ -0,0 +1,54 @@
'use client';
import { useResponsive } from '../../hooks/use-responsive';
import { useUrlState, ViewType } from '../../hooks/use-url-state';
const tabs: { id: ViewType; label: string; icon: string }[] = [
{ id: 'social', label: 'Social', icon: '≡' },
{ id: 'graph', label: 'Graph', icon: '◊' },
{ id: 'swarm', label: 'Swarm', icon: '≋' },
];
export function MobileNav() {
const { view, setView } = useUrlState();
const { isMobile, isTablet } = useResponsive();
if (!isMobile && !isTablet) {
return null;
}
return (
<nav
className="fixed bottom-0 left-0 right-0 h-14 flex items-center justify-around border-t"
style={{
backgroundColor: 'var(--color-bg-card)',
borderColor: 'var(--color-border-soft)',
zIndex: 50,
}}
role="tablist"
data-testid="mobile-nav"
>
{tabs.map((tab) => {
const isActive = view === tab.id;
return (
<button
key={tab.id}
onClick={() => setView(tab.id)}
role="tab"
aria-selected={isActive}
className="flex flex-col items-center justify-center flex-1 h-full gap-1 transition-colors"
style={{
color: isActive ? 'var(--color-accent-green)' : 'var(--color-text-secondary)',
}}
data-testid={`mobile-tab-${tab.id}`}
>
<span className="text-lg leading-none">{tab.icon}</span>
<span className="text-xs">{tab.label}</span>
</button>
);
})}
</nav>
);
}
export default MobileNav;

View file

@ -0,0 +1,216 @@
import type { ReactNode, MouseEventHandler } from 'react';
import { cn } from '../../lib/utils';
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
import { AgentAvatar } from '../shared/agent-avatar';
import { BaseCard } from '../shared/base-card';
interface SocialCardProps {
data: SocialCardData;
className?: string;
selected?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
onJumpToGraph?: (id: string) => void;
onJumpToKanban?: (id: string) => void;
}
const RELATIONSHIP_COLORS = {
unlocks: 'text-emerald-400',
blocks: 'text-amber-400',
};
const DOT_COLORS = {
unlocks: 'bg-emerald-400',
blocks: 'bg-amber-400',
};
function Dot({ color }: { color: 'unlocks' | 'blocks' }) {
return (
<span
className={cn(
'inline-block h-1.5 w-1.5 rounded-full mr-1.5',
DOT_COLORS[color]
)}
/>
);
}
function RelationshipSection({
label,
items,
color,
}: {
label: string;
items: string[];
color: 'unlocks' | 'blocks';
}) {
if (items.length === 0) return null;
return (
<div className="flex items-center gap-1 text-[11px]">
<span className={cn('font-medium', RELATIONSHIP_COLORS[color])}>
{label}:
</span>
<div className="flex flex-wrap gap-x-2">
{items.slice(0, 3).map((id) => (
<span key={id} className="text-text-muted flex items-center">
<Dot color={color} />
{id}
</span>
))}
{items.length > 3 && (
<span className="text-text-muted">+{items.length - 3}</span>
)}
</div>
</div>
);
}
function ViewJumpIcon({
icon,
label,
onClick,
}: {
icon: ReactNode;
label: string;
onClick?: () => void;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="p-1 text-text-muted hover:text-text-body transition-colors rounded hover:bg-white/5"
>
{icon}
</button>
);
}
function GraphIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="4" cy="4" r="2" />
<circle cx="12" cy="4" r="2" />
<circle cx="8" cy="12" r="2" />
<line x1="5.5" y1="5.5" x2="7" y2="10" />
<line x1="10.5" y1="5.5" x2="9" y2="10" />
</svg>
);
}
function KanbanIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="2" y="2" width="4" height="12" rx="1" />
<rect x="6" y="2" width="4" height="8" rx="1" />
<rect x="10" y="2" width="4" height="6" rx="1" />
</svg>
);
}
function ExpandIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="6" cy="6" r="4" />
<line x1="9" y1="9" x2="12.5" y2="12.5" />
</svg>
);
}
export function SocialCard({
data,
className,
selected = false,
onClick,
onJumpToGraph,
onJumpToKanban,
}: SocialCardProps) {
const hasUnlocks = data.unlocks.length > 0;
const hasBlocks = data.blocks.length > 0;
return (
<BaseCard
className={cn('min-w-[220px] max-w-[320px]', className)}
selected={selected}
onClick={onClick}
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-teal-400 font-mono text-sm font-medium">
{data.id}
</span>
<button
type="button"
aria-label="Expand"
className="p-1 text-text-muted hover:text-text-body transition-colors rounded hover:bg-white/5"
>
<ExpandIcon />
</button>
</div>
<h3 className="text-text-strong font-semibold text-sm leading-tight line-clamp-2">
{data.title}
</h3>
{(hasUnlocks || hasBlocks) && (
<div className="space-y-1">
<RelationshipSection label="UNLOCKS" items={data.unlocks} color="unlocks" />
<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) => (
<AgentAvatar
key={agent.name}
name={agent.name}
status={agent.status as AgentStatus}
size="sm"
/>
))}
{data.agents.length > 3 && (
<span className="text-text-muted text-xs ml-1">
+{data.agents.length - 3}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<ViewJumpIcon
icon={<GraphIcon />}
label="View in Graph"
onClick={() => onJumpToGraph?.(data.id)}
/>
<ViewJumpIcon
icon={<KanbanIcon />}
label="View in Kanban"
onClick={() => onJumpToKanban?.(data.id)}
/>
</div>
</div>
</div>
</BaseCard>
);
}

View file

@ -0,0 +1,168 @@
'use client';
import type { SwarmCard as SwarmCardType, AgentRoster } from '../../lib/swarm-cards';
import { Card } from '../../../components/ui/card';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { Plus, Menu, Diamond, Waves, AlertTriangle } from 'lucide-react';
interface SwarmCardProps {
card: SwarmCardType;
onExpand?: () => void;
onMenu?: () => void;
onGraph?: () => void;
onTimeline?: () => void;
}
function formatTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMins > 0) return `${diffMins}m ago`;
return 'just now';
}
const HEALTH_COLORS: Record<string, string> = {
active: 'text-emerald-400',
stale: 'text-amber-400',
stuck: 'text-rose-400',
dead: 'text-red-500',
};
function AgentRosterRow({ agent }: { agent: AgentRoster }) {
return (
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="font-mono text-slate-500">{agent.name}:</span>
<span className="truncate">{agent.currentTask || 'idle'}</span>
</div>
);
}
function ProgressBar({ progress }: { progress: number }) {
const filled = Math.round(progress / 10);
const empty = 10 - filled;
return (
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs">
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</div>
<span className="text-xs text-slate-400">{progress}% done</span>
</div>
);
}
function AttentionList({ items }: { items: string[] }) {
if (items.length === 0) return null;
return (
<div className="space-y-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-400">
ATTENTION:
</span>
{items.slice(0, 3).map((item, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-amber-200/80">
<AlertTriangle className="h-3 w-3 text-amber-400" />
<span className="truncate">{item}</span>
</div>
))}
</div>
);
}
export function SwarmCard({ card, onExpand, onMenu, onGraph, onTimeline }: SwarmCardProps) {
const activeAgents = card.agents.filter((a) => a.status === 'active');
const otherAgents = card.agents.filter((a) => a.status !== 'active');
return (
<Card className="rounded-xl border border-white/[0.06] bg-[#363636] px-3.5 py-3 shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.06)] transition duration-200 hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0 border-slate-600', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
</div>
<button
onClick={onExpand}
className="p-1 rounded hover:bg-white/5 transition-colors"
aria-label="Expand"
>
<Plus className="h-4 w-4 text-slate-500" />
</button>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
AGENTS:
</span>
<div className="flex items-center gap-1 -space-x-1">
{activeAgents.slice(0, 4).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{otherAgents.slice(0, 2).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{card.agents.length > 6 && (
<span className="text-xs text-slate-500 ml-2">+{card.agents.length - 6}</span>
)}
</div>
</div>
{card.agents.filter((a) => a.currentTask).slice(0, 2).map((agent) => (
<AgentRosterRow key={agent.name} agent={agent} />
))}
<AttentionList items={card.attentionItems} />
<ProgressBar progress={card.progress} />
{card.lastActivity && (
<div className="text-xs text-slate-500 italic truncate">
Last activity {formatTimeAgo(card.lastActivity)}
</div>
)}
<div className="flex items-center justify-end gap-1 pt-1 border-t border-white/[0.04]">
<button
onClick={onMenu}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Menu"
>
<Menu className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onGraph}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Graph view"
>
<Diamond className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onTimeline}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Timeline view"
>
<Waves className="h-3.5 w-3.5 text-slate-500" />
</button>
</div>
</div>
</Card>
);
}