feat(bb-ui2): Social and Swarm views with detail panels integrated
This commit is contained in:
parent
976fd0c361
commit
8dd2d01686
11 changed files with 622 additions and 64 deletions
207
src/components/swarm/swarm-detail.tsx
Normal file
207
src/components/swarm/swarm-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/components/swarm/swarm-page.tsx
Normal file
149
src/components/swarm/swarm-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue