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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||
selected: scope.selected,
|
||||
scopeOptions: scope.options,
|
||||
preferBd: true,
|
||||
skipAgentFilter: true,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
116
src/components/social/social-detail.tsx
Normal file
116
src/components/social/social-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/social/social-page.tsx
Normal file
65
src/components/social/social-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue