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

@ -91,40 +91,16 @@ body {
}
body {
background:
radial-gradient(circle at 15% 15%, rgba(60, 80, 120, 0.08) 0%, transparent 35%),
radial-gradient(circle at 85% 20%, rgba(100, 80, 140, 0.06) 0%, transparent 35%),
radial-gradient(circle at 50% 95%, rgba(50, 70, 100, 0.06) 0%, transparent 40%),
linear-gradient(180deg, rgba(20, 22, 30, 0.98) 0%, rgba(10, 11, 14, 0.99) 100%);
background-color: var(--bg-base);
color: var(--color-text-body);
/* Earthy-dark base from PRD (replaces Aero Chrome) */
background-color: var(--color-bg-base);
color: var(--color-text-secondary);
font-family: var(--font-ui-stack);
letter-spacing: -0.011em;
position: relative;
isolation: isolate;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 2rem 2rem;
pointer-events: none;
z-index: -2;
}
body::after {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
opacity: 0.04;
pointer-events: none;
z-index: -1;
}
/* Aero Chrome decorative overlays removed per PRD - earthy-dark is clean/flat */
* {
scrollbar-width: thin;
@ -296,4 +272,8 @@ body::after {
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.6));
}
.workflow-graph-flow .workflow-edge-cycle .react-f
.workflow-graph-flow .workflow-edge-cycle .react-flow__edge-path {
stroke: var(--color-accent-amber);
stroke-dasharray: 4 4;
opacity: 0.9;
}

View file

@ -24,6 +24,7 @@ export default async function Page({ searchParams }: PageProps) {
selected: scope.selected,
scopeOptions: scope.options,
preferBd: true,
skipAgentFilter: true,
});
return (

View file

@ -1,5 +1,6 @@
'use client';
import { useMemo } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { TopBar } from './top-bar';
@ -8,6 +9,12 @@ import { RightPanel } from './right-panel';
import { MobileNav } from './mobile-nav';
import { useUrlState } from '../../hooks/use-url-state';
import { GraphView } from '../graph/graph-view';
import { SocialPage } from '../social/social-page';
import { SocialDetail } from '../social/social-detail';
import { SwarmPage } from '../swarm/swarm-page';
import { SwarmDetail } from '../swarm/swarm-detail';
import { buildSocialCards } from '../../lib/social-cards';
import { buildSwarmCards } from '../../lib/swarm-cards';
export interface UnifiedShellProps {
issues: BeadIssue[];
@ -20,12 +27,28 @@ export interface UnifiedShellProps {
export function UnifiedShell({
issues,
}: UnifiedShellProps) {
const { view, taskId, setTaskId, graphTab, setGraphTab, panel } = useUrlState();
const { view, taskId, setTaskId, swarmId, setSwarmId, graphTab, setGraphTab, panel } = useUrlState();
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
const swarmCards = useMemo(() => buildSwarmCards(issues), [issues]);
const selectedSocialCard = taskId ? socialCards.find(c => c.id === taskId) : null;
const selectedSwarmCard = swarmId ? swarmCards.find(c => c.swarmId === swarmId) : null;
const handleGraphSelect = (id: string) => {
setTaskId(id);
};
const renderRightPanel = () => {
if (view === 'social' && taskId && selectedSocialCard) {
return <SocialDetail data={selectedSocialCard} />;
}
if (view === 'swarm' && swarmId && selectedSwarmCard) {
return <SwarmDetail card={selectedSwarmCard} />;
}
return null;
};
const renderMiddleContent = () => {
if (view === 'graph') {
return (
@ -40,20 +63,27 @@ export function UnifiedShell({
);
}
return (
<div className="p-4" style={{ color: 'var(--color-text-secondary)' }}>
<div className="mb-4">
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
{view === 'social' && 'Social View'}
{view === 'swarm' && 'Swarm View'}
</h2>
<p className="text-sm mt-2">
{view === 'social' && 'Activity feed with blocks/unlocks coming soon'}
{view === 'swarm' && 'Team health dashboard coming soon'}
</p>
</div>
</div>
);
if (view === 'social') {
return (
<SocialPage
issues={issues}
selectedId={taskId ?? undefined}
onSelect={setTaskId}
/>
);
}
if (view === 'swarm') {
return (
<SwarmPage
issues={issues}
selectedId={swarmId ?? undefined}
onSelect={setSwarmId}
/>
);
}
return null;
};
return (
@ -76,7 +106,9 @@ export function UnifiedShell({
</div>
{/* RIGHT PANEL: 17rem detail strip */}
<RightPanel isOpen={panel === 'open'} />
<RightPanel isOpen={panel === 'open'}>
{renderRightPanel()}
</RightPanel>
</div>
{/* MOBILE NAV: Bottom tab bar */}

View file

@ -0,0 +1,116 @@
'use client';
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
import { StatusBadge } from '../shared/status-badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { Plus } from 'lucide-react';
interface SocialDetailProps {
data: SocialCardData;
}
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) 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(undefined, { month: 'short', day: 'numeric' });
}
export function SocialDetail({ data }: SocialDetailProps) {
return (
<div className="space-y-4">
<div className="space-y-2">
<span className="text-teal-400 font-mono text-sm font-medium">
{data.id}
</span>
<h2 className="text-text-primary font-semibold text-base leading-tight">
{data.title}
</h2>
<StatusBadge status={data.status} size="sm" />
</div>
<div className="space-y-1">
<h3 className="text-text-muted text-xs font-semibold uppercase tracking-wider">
Thread
</h3>
<p className="text-text-muted text-sm italic">
Thread placeholder (bb-ui2.13)
</p>
</div>
{data.blocks.length > 0 && (
<div className="space-y-2">
<h3 className="text-amber-400 text-xs font-semibold uppercase tracking-wider">
Blocks
</h3>
<ul className="space-y-1">
{data.blocks.map((id) => (
<li key={id} className="text-text-secondary text-sm font-mono">
{id}
</li>
))}
</ul>
</div>
)}
{data.unlocks.length > 0 && (
<div className="space-y-2">
<h3 className="text-emerald-400 text-xs font-semibold uppercase tracking-wider">
Unlocks
</h3>
<ul className="space-y-1">
{data.unlocks.map((id) => (
<li key={id} className="text-text-secondary text-sm font-mono">
{id}
</li>
))}
</ul>
</div>
)}
<div className="space-y-2">
<h3 className="text-text-muted text-xs font-semibold uppercase tracking-wider">
Assigned
</h3>
<div className="flex items-center gap-2 flex-wrap">
{data.agents.length > 0 ? (
data.agents.map((agent) => (
<AgentAvatar
key={agent.name}
name={agent.name}
status={agent.status as AgentStatus}
size="sm"
/>
))
) : (
<span className="text-text-muted text-sm">No agents</span>
)}
<button
type="button"
className="p-1.5 rounded-md border border-dashed border-white/20 hover:border-white/40 hover:bg-white/5 transition-colors"
aria-label="Add agent"
>
<Plus size={14} className="text-text-muted" />
</button>
</div>
</div>
<div className="space-y-1 pt-2 border-t border-white/10">
<h3 className="text-text-muted text-xs font-semibold uppercase tracking-wider">
Last Activity
</h3>
<p className="text-text-secondary text-sm">
{formatRelativeTime(data.lastActivity)}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
'use client';
import { useMemo, useState } from 'react';
import type { BeadIssue } from '../../lib/types';
import { buildSocialCards } from '../../lib/social-cards';
import { SocialCard } from './social-card';
import { Button } from '@/components/ui/button';
import { ChevronDown } from 'lucide-react';
const INITIAL_LIMIT = 16; // 4x4 grid
interface SocialPageProps {
issues: BeadIssue[];
selectedId?: string;
onSelect: (id: string) => void;
}
export function SocialPage({ issues, selectedId, onSelect }: SocialPageProps) {
const [expanded, setExpanded] = useState(false);
const cards = useMemo(() => buildSocialCards(issues), [issues]);
const visibleCards = expanded ? cards : cards.slice(0, INITIAL_LIMIT);
const hasMore = cards.length > INITIAL_LIMIT;
return (
<div className="p-4">
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem',
maxWidth: '1200px',
margin: '0 auto',
}}
>
{visibleCards.map((card) => (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
/>
))}
</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 {cards.length - INITIAL_LIMIT} more
<ChevronDown className="h-4 w-4" />
</Button>
</div>
)}
{cards.length === 0 && (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
No tasks found.
</div>
)}
</div>
);
}

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>
);
}

View file

@ -91,11 +91,11 @@ export function useUrlState(): UrlState {
}, [updateUrl]);
const setTaskId = useCallback((id: string | null) => {
updateUrl({ task: id });
updateUrl({ task: id, panel: id ? 'open' : null });
}, [updateUrl]);
const setSwarmId = useCallback((id: string | null) => {
updateUrl({ swarm: id });
updateUrl({ swarm: id, panel: id ? 'open' : null });
}, [updateUrl]);
const togglePanel = useCallback(() => {
@ -108,7 +108,7 @@ export function useUrlState(): UrlState {
}, [updateUrl]);
const clearSelection = useCallback(() => {
updateUrl({ task: null, swarm: null, panel: null, graphTab: null });
updateUrl({ task: null, swarm: null, panel: 'closed' });
}, [updateUrl]);
return {

View file

@ -52,12 +52,14 @@ export async function readIssuesForScope(options: {
selected: ProjectScopeOption;
scopeOptions: ProjectScopeOption[];
preferBd?: boolean;
skipAgentFilter?: boolean;
}): Promise<BeadIssueWithProject[]> {
if (options.mode === 'single') {
return readIssuesFromDisk({
projectRoot: options.selected.root,
projectSource: options.selected.source,
preferBd: options.preferBd,
skipAgentFilter: options.skipAgentFilter,
});
}
@ -67,6 +69,7 @@ export async function readIssuesForScope(options: {
projectRoot: project.root,
projectSource: project.source,
preferBd: options.preferBd,
skipAgentFilter: options.skipAgentFilter,
});
return scopeIssuesForProject(project, issues);
}),