feat(bb-ui2): Social and Swarm views with detail panels integrated

This commit is contained in:
zenchantlive 2026-02-16 00:26:31 -08:00
parent 976fd0c361
commit 8dd2d01686
11 changed files with 622 additions and 64 deletions

View file

@ -0,0 +1,207 @@
'use client';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { AlertTriangle, Clock, MessageSquare, Users } from 'lucide-react';
interface SwarmDetailProps {
card: SwarmCardType;
}
const HEALTH_COLORS: Record<string, string> = {
active: 'border-emerald-500/50 text-emerald-400',
stale: 'border-amber-500/50 text-amber-400',
stuck: 'border-rose-500/50 text-rose-400',
dead: 'border-red-600/50 text-red-500',
};
const STATUS_GLOW: Record<string, string> = {
active: 'shadow-[0_0_8px_rgba(52,211,153,0.5)]',
stale: 'shadow-[0_0_8px_rgba(251,191,36,0.4)]',
stuck: 'shadow-[0_0_8px_rgba(244,63,94,0.5)]',
dead: 'shadow-[0_0_8px_rgba(220,38,38,0.6)]',
};
function formatRelativeTime(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} day${diffDays > 1 ? 's' : ''} ago`;
if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
return 'just now';
}
function ProgressBar({ progress }: { progress: number }) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span style={{ color: 'var(--color-text-muted)' }}>Progress</span>
<span className="font-mono" style={{ color: 'var(--color-text-secondary)' }}>
{progress}%
</span>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progress}%`,
backgroundColor:
progress >= 80
? 'var(--color-success)'
: progress >= 50
? 'var(--color-warning)'
: 'var(--color-error)',
}}
/>
</div>
</div>
);
}
function AgentRosterSection({ agents }: { agents: SwarmCardType['agents'] }) {
const active = agents.filter((a) => a.status === 'active').length;
const stale = agents.filter((a) => a.status === 'stale').length;
const stuck = agents.filter((a) => a.status === 'stuck').length;
const dead = agents.filter((a) => a.status === 'dead').length;
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" style={{ color: 'var(--color-text-muted)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Agents ({agents.length})
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{agents.map((agent) => (
<div
key={agent.name}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md border',
STATUS_GLOW[agent.status]
)}
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AgentAvatar name={agent.name} status={agent.status} size="sm" />
<span className="text-xs" style={{ color: 'var(--color-text-primary)' }}>
{agent.name}
</span>
</div>
))}
</div>
{(active > 0 || stale > 0 || stuck > 0 || dead > 0) && (
<div className="flex gap-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{active > 0 && <span className="text-emerald-400">{active} active</span>}
{stale > 0 && <span className="text-amber-400">{stale} stale</span>}
{stuck > 0 && <span className="text-rose-400">{stuck} stuck</span>}
{dead > 0 && <span className="text-red-500">{dead} dead</span>}
</div>
)}
</div>
);
}
function AttentionSection({ items }: { items: string[] }) {
if (items.length === 0) return null;
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Attention ({items.length})
</span>
</div>
<div className="space-y-1.5">
{items.map((item, i) => (
<div
key={i}
className="flex items-start gap-1.5 p-2 rounded-md"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AlertTriangle className="h-3 w-3 text-amber-400 mt-0.5 flex-shrink-0" />
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{item}
</span>
</div>
))}
</div>
</div>
);
}
function LastActivitySection({ date }: { date: Date }) {
return (
<div className="flex items-center gap-1.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Clock className="h-3.5 w-3.5" />
<span>Last activity {formatRelativeTime(date)}</span>
</div>
);
}
function ThreadPlaceholder() {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5" style={{ color: 'var(--color-text-muted)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Thread
</span>
</div>
<div
className="p-3 rounded-md text-center text-xs"
style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-muted)' }}
>
Thread coming soon
</div>
</div>
);
}
export function SwarmDetail({ card }: SwarmDetailProps) {
return (
<div className="space-y-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold" style={{ color: 'var(--color-text-primary)' }}>
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<h3 className="text-sm font-medium line-clamp-2" style={{ color: 'var(--color-text-primary)' }}>
{card.title}
</h3>
</div>
{/* Progress */}
<ProgressBar progress={card.progress} />
{/* Agent Roster */}
<AgentRosterSection agents={card.agents} />
{/* Attention Items */}
<AttentionSection items={card.attentionItems} />
{/* Last Activity */}
<LastActivitySection date={card.lastActivity} />
{/* Thread Placeholder */}
<ThreadPlaceholder />
</div>
);
}

View file

@ -0,0 +1,149 @@
'use client';
import { useMemo, useState } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { buildSwarmCards } from '../../lib/swarm-cards';
import { SwarmCard } from './swarm-card';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { ArrowUpDown, ChevronDown } from 'lucide-react';
type SortOption = 'health' | 'activity' | 'progress' | 'name';
const SORT_LABELS: Record<SortOption, string> = {
health: 'Health',
activity: 'Activity',
progress: 'Progress',
name: 'Name',
};
const INITIAL_LIMIT = 16; // 4x4 grid
const HEALTH_ORDER: Record<string, number> = {
stuck: 0,
stale: 1,
dead: 2,
active: 3,
};
function sortCards(cards: SwarmCardType[], sortBy: SortOption): SwarmCardType[] {
const sorted = [...cards];
switch (sortBy) {
case 'health':
return sorted.sort((a, b) => {
const orderA = HEALTH_ORDER[a.health] ?? 4;
const orderB = HEALTH_ORDER[b.health] ?? 4;
return orderA - orderB;
});
case 'activity':
return sorted.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
case 'progress':
return sorted.sort((a, b) => b.progress - a.progress);
case 'name':
return sorted.sort((a, b) => a.swarmId.localeCompare(b.swarmId));
default:
return sorted;
}
}
interface SwarmPageProps {
issues: BeadIssue[];
selectedId?: string;
onSelect: (id: string) => void;
}
export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
const [sortBy, setSortBy] = useState<SortOption>('health');
const [expanded, setExpanded] = useState(false);
const cards = useMemo(() => buildSwarmCards(issues), [issues]);
const sortedCards = useMemo(() => sortCards(cards, sortBy), [cards, sortBy]);
const visibleCards = expanded ? sortedCards : sortedCards.slice(0, INITIAL_LIMIT);
const hasMore = sortedCards.length > INITIAL_LIMIT;
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4" style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
Swarm View
</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
>
<ArrowUpDown className="h-4 w-4" />
{SORT_LABELS[sortBy]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(SORT_LABELS) as SortOption[]).map((option) => (
<DropdownMenuItem
key={option}
onClick={() => setSortBy(option)}
className={sortBy === option ? 'bg-accent/50' : ''}
>
{SORT_LABELS[option]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
className="grid gap-4"
style={{
gridTemplateColumns: 'repeat(4, 1fr)',
maxWidth: '1200px',
margin: '0 auto',
}}
>
{visibleCards.map((card) => (
<div
key={card.swarmId}
onClick={() => onSelect(card.swarmId)}
className={`cursor-pointer rounded-xl transition-all ${
selectedId === card.swarmId
? 'ring-2 ring-[var(--color-accent-amber)]'
: 'hover:ring-1 hover:ring-white/10'
}`}
>
<SwarmCard card={card} />
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-4">
<Button
variant="outline"
onClick={() => setExpanded(true)}
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
>
Show {sortedCards.length - INITIAL_LIMIT} more
<ChevronDown className="h-4 w-4" />
</Button>
</div>
)}
{sortedCards.length === 0 && (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
No swarms found. Add agents with <code className="px-1 py-0.5 rounded bg-white/5">gt:agent</code> and <code className="px-1 py-0.5 rounded bg-white/5">swarm:*</code> labels.
</div>
)}
</div>
);
}