checkpoint: pre-split branch cleanup

This commit is contained in:
ZenchantLive 2026-03-03 16:43:42 -08:00
parent 4c2ae2e5b7
commit b5db7a7753
276 changed files with 35912 additions and 60119 deletions

View file

@ -1,463 +1,463 @@
'use client';
import { useEffect, useState, useMemo } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { ActivityEvent } from '../../lib/activity';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead';
type AgentTone = {
cardClass: string;
labelClass: string;
ringClass: string;
glowClass: string;
};
export type EventTone = {
label: string;
labelClass: string;
dotClass: string;
cardClass: string;
idClass: string;
};
interface AgentRosterEntry {
name: string;
status: AgentStatus;
lastSeen: string | null;
beadId: string;
}
interface ActivityPanelProps {
issues: BeadIssue[];
collapsed?: boolean;
projectRoot: string;
}
const AGENT_LABEL = 'gt:agent';
// Determine agent status based on last activity
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
if (!lastSeenAt) return 'dead';
const lastSeen = new Date(lastSeenAt);
const now = new Date();
const minutesSince = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
if (minutesSince < 15) return 'active';
if (minutesSince < 30) return 'stale';
if (minutesSince < 60) return 'stuck';
return 'dead';
}
// Get agent name from bead
function extractAgentName(issue: BeadIssue): string | null {
const agentMatch = issue.title.match(/Agent:\s*(\S+)/i);
if (agentMatch) return agentMatch[1];
const agentLabel = issue.labels.find(l => l.startsWith('agent:'));
if (agentLabel) return agentLabel.replace('agent:', '');
return null;
}
// Build agent roster - filter out dead agents unless none are active
function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
const agentIssues = issues.filter(issue =>
issue.labels.includes(AGENT_LABEL) ||
issue.labels.some(l => l.startsWith('gt:agent')) ||
issue.labels.includes('agent')
);
const roster = agentIssues.map(issue => {
const name = extractAgentName(issue) || issue.title.replace('Agent: ', '') || issue.id;
const status = deriveAgentStatus(issue.updated_at);
return {
name,
status,
lastSeen: issue.updated_at,
beadId: issue.id,
};
}).sort((a, b) => {
const statusOrder: Record<AgentStatus, number> = { active: 0, stale: 1, stuck: 2, dead: 3 };
return statusOrder[a.status] - statusOrder[b.status];
});
// Show all non-dead agents, or at least the most recent ones
return roster.filter(a => a.status !== 'dead' || a.lastSeen).slice(0, 10);
}
// Format relative time
export function formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) 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('en-US', { month: 'short', day: 'numeric' });
}
function getAgentTone(status: AgentStatus): AgentTone {
const tones: Record<AgentStatus, AgentTone> = {
active: {
cardClass: 'bg-[var(--status-ready)]',
labelClass: 'text-[#7CB97A]',
ringClass: 'ring-[#7CB97A]/45',
glowClass: 'bg-[#7CB97A]/30',
},
stale: {
cardClass: 'bg-[var(--status-in-progress)]',
labelClass: 'text-[#D4A574]',
ringClass: 'ring-[#D4A574]/45',
glowClass: 'bg-[#D4A574]/30',
},
stuck: {
cardClass: 'bg-[var(--status-blocked)]',
labelClass: 'text-[#C97A7A]',
ringClass: 'ring-[#C97A7A]/45',
glowClass: 'bg-[#C97A7A]/30',
},
dead: {
cardClass: 'bg-[var(--surface-primary)]',
labelClass: 'text-[#A78A94]',
ringClass: 'ring-[#A78A94]/40',
glowClass: 'bg-[#A78A94]/25',
},
};
return tones[status];
}
// reopened=blue, closed=amber, created/opened=green, others semantic
export function getEventTone(kind: string): EventTone {
const normalized = kind.toLowerCase();
const byKind: Record<string, EventTone> = {
created: {
label: 'Created',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[var(--status-ready)]',
idClass: 'text-[#9ACB98]',
},
opened: {
label: 'Opened',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[var(--status-ready)]',
idClass: 'text-[#9ACB98]',
},
closed: {
label: 'Closed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
reopened: {
label: 'Reopened',
labelClass: 'text-[#5B95E8]',
dotClass: 'bg-[#5B95E8]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8DB4EF]',
},
status_changed: {
label: 'Status changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
priority_changed: {
label: 'Priority changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
assignee_changed: {
label: 'Assigned',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
dependency_added: {
label: 'Dependency added',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
dependency_removed: {
label: 'Dependency removed',
labelClass: 'text-[#C97A7A]',
dotClass: 'bg-[#C97A7A]',
cardClass: 'bg-[var(--status-blocked)]',
idClass: 'text-[#D9A9A9]',
},
heartbeat: {
label: 'Heartbeat',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
},
commented: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
},
comment_added: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
},
};
return (
byKind[normalized] || {
label: normalized.replace(/_/g, ' '),
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
}
);
}
export function getInitials(name: string): string {
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
}
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
// Fetch activity history
useEffect(() => {
async function fetchActivity() {
try {
const response = await fetch('/api/activity');
if (response.ok) {
const data = await response.json();
setActivities(data.slice(0, 50)); // Limit to 50 events
}
} catch (error) {
console.error('[ActivityPanel] Failed to fetch activity:', error);
} finally {
setIsLoading(false);
}
}
fetchActivity();
}, []);
// Subscribe to real-time activity
useEffect(() => {
console.log('[ActivityPanel] Connecting to SSE for:', projectRoot);
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
const onActivity = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
console.log('[ActivityPanel] Received activity event:', data);
// data IS the activity event directly (not wrapped in { event: ... })
if (data?.beadId) {
setActivities(prev => [data, ...prev].slice(0, 50));
}
} catch (e) {
// Ignore parse errors
}
};
source.addEventListener('activity', onActivity as EventListener);
return () => {
console.log('[ActivityPanel] Closing SSE connection');
source.removeEventListener('activity', onActivity as EventListener);
source.close();
};
}, [projectRoot]);
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
if (collapsed) {
return (
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
{/* Collapsed Agent Icons with ZFC Rings */}
<div className="flex flex-col gap-4">
{agentRoster.slice(0, 6).map(agent => (
<div key={agent.beadId} className="relative group cursor-help" title={`${agent.name} (${agent.status})`}>
<div className={cn(
"absolute -inset-1 rounded-full blur-[2px] transition-opacity duration-500",
agent.status === 'active' ? 'bg-[#7CB97A]/20 opacity-100 animate-pulse' :
agent.status === 'stale' ? 'bg-[#D4A574]/14 opacity-80' :
agent.status === 'stuck' ? 'bg-[#C97A7A]/20 opacity-100' : 'bg-[#A78A94]/18 opacity-90'
)} />
<Avatar className={cn(
"h-9 w-9 ring-2 transition-all duration-300 relative z-10",
agent.status === 'active' ? 'ring-[#7CB97A]/45' :
agent.status === 'stale' ? 'ring-[#D4A574]/45' :
agent.status === 'stuck' ? 'ring-[#C97A7A]/45' : 'ring-[#A78A94]/40'
)}>
<AvatarFallback className="text-[10px] font-bold bg-[var(--surface-primary)] text-text-muted">
{getInitials(agent.name)}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
<div className="w-6 h-[1px] bg-white/20 mx-auto" />
{/* Activity Pulses */}
<div className="flex flex-col gap-2 opacity-40">
{activities.slice(0, 8).map((act) => (
<div key={act.id} className={cn(
"w-1 h-1 rounded-full",
getEventTone(act.kind).dotClass
)} />
))}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-[var(--surface-secondary)]">
{/* AGENT ROSTER SECTION */}
<div className="flex-shrink-0 p-4 bg-[var(--surface-primary)] border-b border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Live Agents</h3>
</div>
<div className="text-[10px] font-mono text-[#7CB97A]/80 bg-[#7CB97A]/15 px-2 py-0.5 rounded shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)]">
{activeAgents} ONLINE
</div>
</div>
{agentRoster.length === 0 ? (
<p className="text-xs text-text-muted/40 italic text-center py-4">No agents broadcasting</p>
) : (
<div className="grid grid-cols-1 gap-2">
{agentRoster.map(agent => (
<div key={agent.beadId} className={cn(
'group flex items-center gap-3 p-2 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.92)]',
getAgentTone(agent.status).cardClass,
)}>
<div className="relative">
<div className={cn(
"absolute -inset-0.5 rounded-full blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity",
getAgentTone(agent.status).glowClass
)} />
<Avatar className={cn("h-8 w-8 relative z-10 ring-1", getAgentTone(agent.status).ringClass)}>
<AvatarFallback className="text-[10px] font-bold bg-[var(--surface-primary)]">
{getInitials(agent.name)}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-semibold text-text-primary group-hover:text-white transition-colors">{agent.name}</span>
<div className="flex items-center gap-1.5">
<span className={cn(
"text-[9px] uppercase tracking-wider font-bold",
getAgentTone(agent.status).labelClass
)}>
{agent.status}
</span>
<span className="text-[9px] text-text-muted/40 font-mono">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* ACTIVITY FEED SECTION */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="p-4 flex items-center gap-2 shadow-[0_14px_24px_-24px_rgba(0,0,0,0.9)]">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-muted/60"><path d="M22 12h-4l-3 9L9 3l-3 9H2"></path></svg>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Telemetry Stream</h3>
</div>
<ScrollArea className="flex-1">
{isLoading ? (
<div className="p-10 flex flex-col items-center gap-3">
<div className="w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full animate-spin" />
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
</div>
) : activities.length === 0 ? (
<div className="p-10 text-center opacity-30">
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
</div>
) : (
<div className="p-3 space-y-3">
{activities.map((activity) => {
const eventTone = getEventTone(activity.kind);
return (
<div key={activity.id} className="group relative">
<div className={cn(
"p-3 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.94)]",
eventTone.cardClass
)}>
<div className="flex items-center gap-2 mb-1.5">
<div className={cn(
"w-1.5 h-1.5 rounded-full shrink-0",
eventTone.dotClass
)} />
<span className={cn("text-[10px] font-bold uppercase tracking-wider", eventTone.labelClass)}>
{eventTone.label}
</span>
<span className="text-[9px] text-text-muted/30 font-mono ml-auto">
{formatRelativeTime(activity.timestamp)}
</span>
</div>
<p className="text-xs font-medium text-text-secondary leading-snug line-clamp-2 mb-2 group-hover:text-text-primary transition-colors">
{activity.beadTitle}
</p>
<div className="flex items-center justify-between">
<span className={cn("text-[10px] font-mono", eventTone.idClass)}>
{activity.beadId}
</span>
{activity.actor && (
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-full bg-white/10 shadow-[0_0_8px_rgba(0,0,0,0.45)] flex items-center justify-center text-[6px] font-bold">
{activity.actor[0].toUpperCase()}
</div>
<span className="text-[9px] text-text-muted/60">{activity.actor}</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
</div>
);
}
'use client';
import { useEffect, useState, useMemo } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { ActivityEvent } from '../../lib/activity';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead';
type AgentTone = {
cardClass: string;
labelClass: string;
ringClass: string;
glowClass: string;
};
export type EventTone = {
label: string;
labelClass: string;
dotClass: string;
cardClass: string;
idClass: string;
};
interface AgentRosterEntry {
name: string;
status: AgentStatus;
lastSeen: string | null;
beadId: string;
}
interface ActivityPanelProps {
issues: BeadIssue[];
collapsed?: boolean;
projectRoot: string;
}
const AGENT_LABEL = 'gt:agent';
// Determine agent status based on last activity
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
if (!lastSeenAt) return 'dead';
const lastSeen = new Date(lastSeenAt);
const now = new Date();
const minutesSince = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
if (minutesSince < 15) return 'active';
if (minutesSince < 30) return 'stale';
if (minutesSince < 60) return 'stuck';
return 'dead';
}
// Get agent name from bead
function extractAgentName(issue: BeadIssue): string | null {
const agentMatch = issue.title.match(/Agent:\s*(\S+)/i);
if (agentMatch) return agentMatch[1];
const agentLabel = issue.labels.find(l => l.startsWith('agent:'));
if (agentLabel) return agentLabel.replace('agent:', '');
return null;
}
// Build agent roster - filter out dead agents unless none are active
function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
const agentIssues = issues.filter(issue =>
issue.labels.includes(AGENT_LABEL) ||
issue.labels.some(l => l.startsWith('gt:agent')) ||
issue.labels.includes('agent')
);
const roster = agentIssues.map(issue => {
const name = extractAgentName(issue) || issue.title.replace('Agent: ', '') || issue.id;
const status = deriveAgentStatus(issue.updated_at);
return {
name,
status,
lastSeen: issue.updated_at,
beadId: issue.id,
};
}).sort((a, b) => {
const statusOrder: Record<AgentStatus, number> = { active: 0, stale: 1, stuck: 2, dead: 3 };
return statusOrder[a.status] - statusOrder[b.status];
});
// Show all non-dead agents, or at least the most recent ones
return roster.filter(a => a.status !== 'dead' || a.lastSeen).slice(0, 10);
}
// Format relative time
export function formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) 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('en-US', { month: 'short', day: 'numeric' });
}
function getAgentTone(status: AgentStatus): AgentTone {
const tones: Record<AgentStatus, AgentTone> = {
active: {
cardClass: 'bg-[var(--status-ready)]',
labelClass: 'text-[#7CB97A]',
ringClass: 'ring-[#7CB97A]/45',
glowClass: 'bg-[#7CB97A]/30',
},
stale: {
cardClass: 'bg-[var(--status-in-progress)]',
labelClass: 'text-[#D4A574]',
ringClass: 'ring-[#D4A574]/45',
glowClass: 'bg-[#D4A574]/30',
},
stuck: {
cardClass: 'bg-[var(--status-blocked)]',
labelClass: 'text-[#C97A7A]',
ringClass: 'ring-[#C97A7A]/45',
glowClass: 'bg-[#C97A7A]/30',
},
dead: {
cardClass: 'bg-[var(--surface-primary)]',
labelClass: 'text-[#A78A94]',
ringClass: 'ring-[#A78A94]/40',
glowClass: 'bg-[#A78A94]/25',
},
};
return tones[status];
}
// reopened=blue, closed=amber, created/opened=green, others semantic
export function getEventTone(kind: string): EventTone {
const normalized = kind.toLowerCase();
const byKind: Record<string, EventTone> = {
created: {
label: 'Created',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[var(--status-ready)]',
idClass: 'text-[#9ACB98]',
},
opened: {
label: 'Opened',
labelClass: 'text-[#7CB97A]',
dotClass: 'bg-[#7CB97A]',
cardClass: 'bg-[var(--status-ready)]',
idClass: 'text-[#9ACB98]',
},
closed: {
label: 'Closed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
reopened: {
label: 'Reopened',
labelClass: 'text-[#5B95E8]',
dotClass: 'bg-[#5B95E8]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8DB4EF]',
},
status_changed: {
label: 'Status changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
priority_changed: {
label: 'Priority changed',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
assignee_changed: {
label: 'Assigned',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
dependency_added: {
label: 'Dependency added',
labelClass: 'text-[#D4A574]',
dotClass: 'bg-[#D4A574]',
cardClass: 'bg-[var(--status-in-progress)]',
idClass: 'text-[#DAB891]',
},
dependency_removed: {
label: 'Dependency removed',
labelClass: 'text-[#C97A7A]',
dotClass: 'bg-[#C97A7A]',
cardClass: 'bg-[var(--status-blocked)]',
idClass: 'text-[#D9A9A9]',
},
heartbeat: {
label: 'Heartbeat',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
},
commented: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
},
comment_added: {
label: 'Commented',
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
},
};
return (
byKind[normalized] || {
label: normalized.replace(/_/g, ' '),
labelClass: 'text-[#5BA8A0]',
dotClass: 'bg-[#5BA8A0]',
cardClass: 'bg-[var(--surface-primary)]',
idClass: 'text-[#8BC9C1]',
}
);
}
export function getInitials(name: string): string {
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
}
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
// Fetch activity history
useEffect(() => {
async function fetchActivity() {
try {
const response = await fetch('/api/activity');
if (response.ok) {
const data = await response.json();
setActivities(data.slice(0, 50)); // Limit to 50 events
}
} catch (error) {
console.error('[ActivityPanel] Failed to fetch activity:', error);
} finally {
setIsLoading(false);
}
}
fetchActivity();
}, []);
// Subscribe to real-time activity
useEffect(() => {
console.log('[ActivityPanel] Connecting to SSE for:', projectRoot);
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
const onActivity = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
console.log('[ActivityPanel] Received activity event:', data);
// data IS the activity event directly (not wrapped in { event: ... })
if (data?.beadId) {
setActivities(prev => [data, ...prev].slice(0, 50));
}
} catch (e) {
// Ignore parse errors
}
};
source.addEventListener('activity', onActivity as EventListener);
return () => {
console.log('[ActivityPanel] Closing SSE connection');
source.removeEventListener('activity', onActivity as EventListener);
source.close();
};
}, [projectRoot]);
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
if (collapsed) {
return (
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[var(--surface-secondary)]">
{/* Collapsed Agent Icons with ZFC Rings */}
<div className="flex flex-col gap-4">
{agentRoster.slice(0, 6).map(agent => (
<div key={agent.beadId} className="relative group cursor-help" title={`${agent.name} (${agent.status})`}>
<div className={cn(
"absolute -inset-1 rounded-full blur-[2px] transition-opacity duration-500",
agent.status === 'active' ? 'bg-[#7CB97A]/20 opacity-100 animate-pulse' :
agent.status === 'stale' ? 'bg-[#D4A574]/14 opacity-80' :
agent.status === 'stuck' ? 'bg-[#C97A7A]/20 opacity-100' : 'bg-[#A78A94]/18 opacity-90'
)} />
<Avatar className={cn(
"h-9 w-9 ring-2 transition-all duration-300 relative z-10",
agent.status === 'active' ? 'ring-[#7CB97A]/45' :
agent.status === 'stale' ? 'ring-[#D4A574]/45' :
agent.status === 'stuck' ? 'ring-[#C97A7A]/45' : 'ring-[#A78A94]/40'
)}>
<AvatarFallback className="text-[10px] font-bold bg-[var(--surface-primary)] text-text-muted">
{getInitials(agent.name)}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
<div className="w-6 h-[1px] bg-white/20 mx-auto" />
{/* Activity Pulses */}
<div className="flex flex-col gap-2 opacity-40">
{activities.slice(0, 8).map((act) => (
<div key={act.id} className={cn(
"w-1 h-1 rounded-full",
getEventTone(act.kind).dotClass
)} />
))}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-[var(--surface-secondary)]">
{/* AGENT ROSTER SECTION */}
<div className="flex-shrink-0 p-4 bg-[var(--surface-primary)] border-b border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Live Agents</h3>
</div>
<div className="text-[10px] font-mono text-[#7CB97A]/80 bg-[#7CB97A]/15 px-2 py-0.5 rounded shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)]">
{activeAgents} ONLINE
</div>
</div>
{agentRoster.length === 0 ? (
<p className="text-xs text-text-muted/40 italic text-center py-4">No agents broadcasting</p>
) : (
<div className="grid grid-cols-1 gap-2">
{agentRoster.map(agent => (
<div key={agent.beadId} className={cn(
'group flex items-center gap-3 p-2 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.92)]',
getAgentTone(agent.status).cardClass,
)}>
<div className="relative">
<div className={cn(
"absolute -inset-0.5 rounded-full blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity",
getAgentTone(agent.status).glowClass
)} />
<Avatar className={cn("h-8 w-8 relative z-10 ring-1", getAgentTone(agent.status).ringClass)}>
<AvatarFallback className="text-[10px] font-bold bg-[var(--surface-primary)]">
{getInitials(agent.name)}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-semibold text-text-primary group-hover:text-white transition-colors">{agent.name}</span>
<div className="flex items-center gap-1.5">
<span className={cn(
"text-[9px] uppercase tracking-wider font-bold",
getAgentTone(agent.status).labelClass
)}>
{agent.status}
</span>
<span className="text-[9px] text-text-muted/40 font-mono">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'N/A'}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* ACTIVITY FEED SECTION */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="p-4 flex items-center gap-2 shadow-[0_14px_24px_-24px_rgba(0,0,0,0.9)]">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-muted/60"><path d="M22 12h-4l-3 9L9 3l-3 9H2"></path></svg>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Telemetry Stream</h3>
</div>
<ScrollArea className="flex-1">
{isLoading ? (
<div className="p-10 flex flex-col items-center gap-3">
<div className="w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full animate-spin" />
<span className="text-[10px] font-mono text-text-muted">SYNCING...</span>
</div>
) : activities.length === 0 ? (
<div className="p-10 text-center opacity-30">
<p className="text-[10px] font-mono">VOID_STREAM_NULL</p>
</div>
) : (
<div className="p-3 space-y-3">
{activities.map((activity) => {
const eventTone = getEventTone(activity.kind);
return (
<div key={activity.id} className="group relative">
<div className={cn(
"p-3 rounded-xl transition-all duration-300 shadow-[0_14px_24px_-14px_rgba(0,0,0,0.94)]",
eventTone.cardClass
)}>
<div className="flex items-center gap-2 mb-1.5">
<div className={cn(
"w-1.5 h-1.5 rounded-full shrink-0",
eventTone.dotClass
)} />
<span className={cn("text-[10px] font-bold uppercase tracking-wider", eventTone.labelClass)}>
{eventTone.label}
</span>
<span className="text-[9px] text-text-muted/30 font-mono ml-auto">
{formatRelativeTime(activity.timestamp)}
</span>
</div>
<p className="text-xs font-medium text-text-secondary leading-snug line-clamp-2 mb-2 group-hover:text-text-primary transition-colors">
{activity.beadTitle}
</p>
<div className="flex items-center justify-between">
<span className={cn("text-[10px] font-mono", eventTone.idClass)}>
{activity.beadId}
</span>
{activity.actor && (
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-full bg-white/10 shadow-[0_0_8px_rgba(0,0,0,0.45)] flex items-center justify-center text-[6px] font-bold">
{activity.actor[0].toUpperCase()}
</div>
<span className="text-[9px] text-text-muted/60">{activity.actor}</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
</div>
);
}

View file

@ -1,187 +1,187 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { ActivityEvent } from '../../lib/activity';
import { useArchetypes } from '../../hooks/use-archetypes';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn, getArchetypeDisplayChar } from '@/lib/utils';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { getEventTone, formatRelativeTime, getInitials } from './activity-panel';
export interface SwarmCommandFeedProps {
epicId: string;
issues: BeadIssue[];
projectRoot: string;
}
export function SwarmCommandFeed({ epicId, issues, projectRoot }: SwarmCommandFeedProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const { archetypes } = useArchetypes(projectRoot);
// 1. Compute Contextual Tasks
const contextBeads = useMemo(() => {
return issues.filter(issue => {
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
}, [issues, epicId]);
const contextBeadIds = useMemo(() => new Set(contextBeads.map(b => b.id)), [contextBeads]);
// 2. Compute "Active Squad Roster" (Unique assignees working on in_progress tasks for THIS epic)
const rosterEntries = useMemo(() => {
const activeAssignees = new Set<string>();
const entries: { assignee: string, currentTask: string, archetype?: any }[] = [];
contextBeads.forEach(b => {
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
activeAssignees.add(b.assignee);
const assigneeStr = b.assignee.toLowerCase();
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
entries.push({
assignee: b.assignee,
currentTask: b.title,
archetype: matchedArchetype
});
}
});
return entries;
}, [contextBeads, archetypes]);
// 3. Load historical activity filtered to this epic's children
useEffect(() => {
if (contextBeadIds.size === 0) return;
async function loadHistory() {
try {
const res = await fetch(`/api/activity?projectRoot=${encodeURIComponent(projectRoot)}`);
if (!res.ok) return;
const data = (await res.json()) as ActivityEvent[];
const filtered = data.filter(e => e?.beadId && contextBeadIds.has(e.beadId));
setActivities(filtered.slice(0, 100));
} catch {
// ignore
}
}
void loadHistory();
}, [projectRoot, contextBeadIds]);
// 4. Subscribe to real-time activity, filtering ONLY for this epic's children
useEffect(() => {
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
const onActivity = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data) as ActivityEvent;
// ONLY accept events for beads that belong to this Epic
if (data?.beadId && contextBeadIds.has(data.beadId)) {
setActivities(prev => [data, ...prev].slice(0, 100)); // Keep a healthy buffer for terminal feel
}
} catch (e) {
// Ignore parse errors
}
};
source.addEventListener('activity', onActivity as EventListener);
return () => {
source.removeEventListener('activity', onActivity as EventListener);
source.close();
};
}, [projectRoot, contextBeadIds]);
return (
<div className="flex flex-col h-full bg-[var(--surface-secondary)] border-l border-[var(--ui-border-soft)]">
{/* SQUAD ROSTER SECTION */}
<div className="flex-shrink-0 p-4 bg-[var(--surface-primary)] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)] z-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--ui-text-primary)]">Active Squad</h3>
</div>
<div className="text-[10px] font-mono text-[#7CB97A]/80 bg-[#7CB97A]/15 px-2 py-0.5 rounded border border-[#7CB97A]/30">
{rosterEntries.length} DEPLOYED
</div>
</div>
{rosterEntries.length === 0 ? (
<div className="text-xs text-[var(--ui-text-muted)] italic text-center py-4 border border-dashed border-white/5 rounded-lg">
No agents currently operating on this Epic.
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{rosterEntries.map((agent, i) => (
<div key={i} className="flex gap-3 p-2.5 bg-[var(--surface-elevated)] border border-[var(--ui-border-soft)] rounded-xl items-center shadow-lg transition-all hover:border-[var(--ui-accent-info)]/30">
<div className="relative">
<div className="absolute -inset-0.5 rounded-full blur-[2px] opacity-70 bg-emerald-500/20" />
<Avatar className="h-9 w-9 relative z-10 ring-2 ring-emerald-500/40">
<AvatarFallback className="text-[10px] font-bold" style={{ backgroundColor: agent.archetype?.color ? `${agent.archetype.color}20` : '#252525', color: agent.archetype?.color || '#fff' }}>
{agent.archetype ? getArchetypeDisplayChar(agent.archetype) : getInitials(agent.assignee)}
</AvatarFallback>
</Avatar>
</div>
<div className="flex-col flex-1 min-w-0">
<div className="text-sm font-bold text-[var(--ui-text-primary)] truncate">{agent.assignee}</div>
<div className="text-[10px] text-[var(--ui-accent-warning)] truncate font-mono mt-0.5">
&gt; {agent.currentTask}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* STREAMING LOG / TERMINAL SECTION */}
<div className="flex-1 min-h-0 flex flex-col pt-2 bg-black/40">
<div className="px-4 py-2 flex items-center justify-between border-b border-[var(--ui-border-soft)]/50">
<div className="flex items-center gap-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-cyan-500"><path d="M4 17l6-6-6-6M12 19h8"></path></svg>
<h3 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-[var(--ui-text-muted)]">Live Command Feed</h3>
</div>
<div className="text-[9px] font-mono text-[var(--ui-text-muted)]/50 uppercase">Tailing Logs</div>
</div>
<ScrollArea className="flex-1 p-3">
{activities.length === 0 ? (
<div className="p-10 text-center opacity-30 flex flex-col items-center gap-2">
<div className="w-1 h-4 bg-cyan-500 animate-pulse" />
<p className="text-[10px] font-mono uppercase tracking-widest text-cyan-500">Waiting for agent signals...</p>
</div>
) : (
<div className="space-y-1">
{activities.map((activity) => {
const eventTone = getEventTone(activity.kind);
return (
<div key={activity.id} className="group flex gap-3 p-1.5 rounded bg-transparent hover:bg-white/5 transition-colors items-start">
<div className={cn("text-[9px] font-mono whitespace-nowrap pt-0.5", eventTone.idClass)}>
[{formatRelativeTime(activity.timestamp)}]
</div>
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex items-center gap-1.5 flex-wrap">
{activity.actor && (
<span className="text-[10px] font-bold text-white uppercase">{activity.actor.split(' ')[0]}</span>
)}
<span className={cn("text-[10px] font-mono", eventTone.labelClass)}>
{eventTone.label.toLowerCase()}
</span>
<span className="text-[10px] text-zinc-400 font-mono truncate max-w-[120px]">
{activity.beadId}
</span>
</div>
<div className="text-[11px] text-zinc-300 leading-snug break-words">
{activity.beadTitle}
</div>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
</div>
);
}
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { ActivityEvent } from '../../lib/activity';
import { useArchetypes } from '../../hooks/use-archetypes';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn, getArchetypeDisplayChar } from '@/lib/utils';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { getEventTone, formatRelativeTime, getInitials } from './activity-panel';
export interface SwarmCommandFeedProps {
epicId: string;
issues: BeadIssue[];
projectRoot: string;
}
export function SwarmCommandFeed({ epicId, issues, projectRoot }: SwarmCommandFeedProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const { archetypes } = useArchetypes(projectRoot);
// 1. Compute Contextual Tasks
const contextBeads = useMemo(() => {
return issues.filter(issue => {
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
}, [issues, epicId]);
const contextBeadIds = useMemo(() => new Set(contextBeads.map(b => b.id)), [contextBeads]);
// 2. Compute "Active Squad Roster" (Unique assignees working on in_progress tasks for THIS epic)
const rosterEntries = useMemo(() => {
const activeAssignees = new Set<string>();
const entries: { assignee: string, currentTask: string, archetype?: any }[] = [];
contextBeads.forEach(b => {
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
activeAssignees.add(b.assignee);
const assigneeStr = b.assignee.toLowerCase();
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
entries.push({
assignee: b.assignee,
currentTask: b.title,
archetype: matchedArchetype
});
}
});
return entries;
}, [contextBeads, archetypes]);
// 3. Load historical activity filtered to this epic's children
useEffect(() => {
if (contextBeadIds.size === 0) return;
async function loadHistory() {
try {
const res = await fetch(`/api/activity?projectRoot=${encodeURIComponent(projectRoot)}`);
if (!res.ok) return;
const data = (await res.json()) as ActivityEvent[];
const filtered = data.filter(e => e?.beadId && contextBeadIds.has(e.beadId));
setActivities(filtered.slice(0, 100));
} catch {
// ignore
}
}
void loadHistory();
}, [projectRoot, contextBeadIds]);
// 4. Subscribe to real-time activity, filtering ONLY for this epic's children
useEffect(() => {
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
const onActivity = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data) as ActivityEvent;
// ONLY accept events for beads that belong to this Epic
if (data?.beadId && contextBeadIds.has(data.beadId)) {
setActivities(prev => [data, ...prev].slice(0, 100)); // Keep a healthy buffer for terminal feel
}
} catch (e) {
// Ignore parse errors
}
};
source.addEventListener('activity', onActivity as EventListener);
return () => {
source.removeEventListener('activity', onActivity as EventListener);
source.close();
};
}, [projectRoot, contextBeadIds]);
return (
<div className="flex flex-col h-full bg-[var(--surface-secondary)] border-l border-[var(--ui-border-soft)]">
{/* SQUAD ROSTER SECTION */}
<div className="flex-shrink-0 p-4 bg-[var(--surface-primary)] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)] z-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--ui-text-primary)]">Active Squad</h3>
</div>
<div className="text-[10px] font-mono text-[#7CB97A]/80 bg-[#7CB97A]/15 px-2 py-0.5 rounded border border-[#7CB97A]/30">
{rosterEntries.length} DEPLOYED
</div>
</div>
{rosterEntries.length === 0 ? (
<div className="text-xs text-[var(--ui-text-muted)] italic text-center py-4 border border-dashed border-white/5 rounded-lg">
No agents currently operating on this Epic.
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{rosterEntries.map((agent, i) => (
<div key={i} className="flex gap-3 p-2.5 bg-[var(--surface-elevated)] border border-[var(--ui-border-soft)] rounded-xl items-center shadow-lg transition-all hover:border-[var(--ui-accent-info)]/30">
<div className="relative">
<div className="absolute -inset-0.5 rounded-full blur-[2px] opacity-70 bg-emerald-500/20" />
<Avatar className="h-9 w-9 relative z-10 ring-2 ring-emerald-500/40">
<AvatarFallback className="text-[10px] font-bold" style={{ backgroundColor: agent.archetype?.color ? `${agent.archetype.color}20` : '#252525', color: agent.archetype?.color || '#fff' }}>
{agent.archetype ? getArchetypeDisplayChar(agent.archetype) : getInitials(agent.assignee)}
</AvatarFallback>
</Avatar>
</div>
<div className="flex-col flex-1 min-w-0">
<div className="text-sm font-bold text-[var(--ui-text-primary)] truncate">{agent.assignee}</div>
<div className="text-[10px] text-[var(--ui-accent-warning)] truncate font-mono mt-0.5">
&gt; {agent.currentTask}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* STREAMING LOG / TERMINAL SECTION */}
<div className="flex-1 min-h-0 flex flex-col pt-2 bg-black/40">
<div className="px-4 py-2 flex items-center justify-between border-b border-[var(--ui-border-soft)]/50">
<div className="flex items-center gap-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-cyan-500"><path d="M4 17l6-6-6-6M12 19h8"></path></svg>
<h3 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-[var(--ui-text-muted)]">Live Command Feed</h3>
</div>
<div className="text-[9px] font-mono text-[var(--ui-text-muted)]/50 uppercase">Tailing Logs</div>
</div>
<ScrollArea className="flex-1 p-3">
{activities.length === 0 ? (
<div className="p-10 text-center opacity-30 flex flex-col items-center gap-2">
<div className="w-1 h-4 bg-cyan-500 animate-pulse" />
<p className="text-[10px] font-mono uppercase tracking-widest text-cyan-500">Waiting for agent signals...</p>
</div>
) : (
<div className="space-y-1">
{activities.map((activity) => {
const eventTone = getEventTone(activity.kind);
return (
<div key={activity.id} className="group flex gap-3 p-1.5 rounded bg-transparent hover:bg-white/5 transition-colors items-start">
<div className={cn("text-[9px] font-mono whitespace-nowrap pt-0.5", eventTone.idClass)}>
[{formatRelativeTime(activity.timestamp)}]
</div>
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex items-center gap-1.5 flex-wrap">
{activity.actor && (
<span className="text-[10px] font-bold text-white uppercase">{activity.actor.split(' ')[0]}</span>
)}
<span className={cn("text-[10px] font-mono", eventTone.labelClass)}>
{eventTone.label.toLowerCase()}
</span>
<span className="text-[10px] text-zinc-400 font-mono truncate max-w-[120px]">
{activity.beadId}
</span>
</div>
<div className="text-[11px] text-zinc-300 leading-snug break-words">
{activity.beadTitle}
</div>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,219 +1,219 @@
'use client';
import type { GraphNode } from '../../lib/graph';
import type { PathWorkspace } from '../../lib/graph-view';
import type { BeadIssue } from '../../lib/types';
/** Props for an individual flow card in the dependency strip. */
interface FlowCardProps {
/** The graph node data for this card. */
node: GraphNode;
/** Whether this card is the currently selected/focused task. */
selected: boolean;
/** Number of issues blocking this node. */
blockedBy: number;
/** Number of issues this node blocks. */
blocks: number;
/** Callback fired when the user clicks this card. */
onSelect: (id: string) => void;
}
/** Props for the DependencyFlowStrip component. */
interface DependencyFlowStripProps {
/** The computed path workspace containing blockers, focus, and dependents. */
workspace: PathWorkspace;
/** ID of the currently selected task, or null. */
selectedId: string | null;
/** Map of issue ID to blocker/blocks counts. */
signalById: Map<string, { blockedBy: number; blocks: number }>;
/** Callback fired when the user selects a card. */
onSelect: (id: string) => void;
}
/**
* Returns the Tailwind background color class for a status dot indicator.
*/
function statusDot(status: BeadIssue['status']): string {
switch (status) {
case 'open':
return 'bg-sky-400';
case 'in_progress':
return 'bg-amber-400';
case 'blocked':
return 'bg-rose-500';
case 'deferred':
return 'bg-slate-400';
case 'closed':
return 'bg-emerald-400';
case 'pinned':
return 'bg-violet-400';
case 'hooked':
return 'bg-orange-400';
default:
return 'bg-zinc-500';
}
}
/**
* A compact card representing a single node in the dependency flow.
* Shows ID, title, status, and blocker/blocks counts.
*/
function FlowCard({ node, selected, blockedBy, blocks, onSelect }: FlowCardProps) {
return (
<button
type="button"
onClick={() => onSelect(node.id)}
className={`workflow-card w-full rounded-xl px-3 py-2.5 text-left transition duration-200 ${selected
? 'workflow-card-selected'
: 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]'
}`}
>
{/* Header: node ID + status dot */}
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[9px] tracking-[0.04em] text-text-muted/80">{node.id}</span>
<span className={`h-2 w-2 shrink-0 rounded-full ${statusDot(node.status)}`} />
</div>
{/* Node title - truncates at 2 lines */}
<p className="mt-1 text-[12px] font-semibold leading-tight text-text-strong line-clamp-2">{node.title}</p>
{/* Dependency signal counts */}
<p className="mt-1 text-[10px] text-text-body">
{blockedBy} blockers &bull; {blocks} dependents
</p>
</button>
);
}
/**
* Renders a section header with a count badge.
*/
function SectionHeader({ label, count, color }: { label: string; count: number; color: string }) {
return (
<div className="flex items-center gap-2 mb-2">
<span className={`text-[10px] font-bold uppercase tracking-[0.15em] ${color}`}>{label}</span>
<span className="rounded-md bg-white/5 px-1.5 py-0.5 text-[9px] font-bold text-text-muted/60">{count}</span>
</div>
);
}
/**
* Renders the dependency flow as three responsive sections stacked vertically:
* Blocked By, Selected/Focus, and Blocks (Dependents).
* Each section uses a responsive wrapping grid so cards never overflow.
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
*/
import { useState } from 'react';
// ... (FlowCardProps, DependencyFlowStripProps, statusDot, FlowCard, SectionHeader definitions remain unchanged)
/**
* Renders the dependency flow as three responsive sections stacked vertically:
* Blocked By, Selected/Focus, and Blocks (Dependents).
* Each section uses a responsive wrapping grid so cards never overflow.
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
*/
export function DependencyFlowStrip({ workspace, selectedId, signalById, onSelect }: DependencyFlowStripProps) {
const [minimized, setMinimized] = useState(false);
// Flatten the multi-hop blocker/dependent arrays for display
const blockerNodes = workspace.blockers.flat();
const dependentNodes = workspace.dependents.flat();
return (
<div className="rounded-2xl border border-white/5 bg-white/[0.01] px-5 py-4 ring-1 ring-white/5 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">
Dependency Flow
</h3>
<button
onClick={() => setMinimized(!minimized)}
className="rounded p-1 hover:bg-white/5 text-text-muted transition-colors"
title={minimized ? "Expand" : "Minimize"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform duration-200 ${minimized ? 'rotate-180' : ''}`}
>
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
</div>
{/* Responsive three-column layout: stacks on mobile, side-by-side on desktop */}
{!minimized && (
<div className="grid gap-4 md:grid-cols-3 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Blocked By section */}
<div>
<SectionHeader label="Blocked By" count={blockerNodes.length} color="text-rose-400/70" />
{blockerNodes.length > 0 ? (
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
{blockerNodes.map((node) => (
<FlowCard
key={node.id}
node={node}
selected={selectedId === node.id}
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
blocks={signalById.get(node.id)?.blocks ?? 0}
onSelect={onSelect}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
<p className="text-[10px] text-text-muted/40">No blockers</p>
</div>
)}
</div>
{/* Selected / Focused task section */}
<div>
<SectionHeader label="Selected" count={workspace.focus ? 1 : 0} color="text-sky-400/70" />
{workspace.focus ? (
<FlowCard
node={workspace.focus}
selected
blockedBy={signalById.get(workspace.focus.id)?.blockedBy ?? 0}
blocks={signalById.get(workspace.focus.id)?.blocks ?? 0}
onSelect={onSelect}
/>
) : (
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
<p className="text-[10px] text-text-muted/40">Select a task</p>
</div>
)}
</div>
{/* Blocks (Dependents) section */}
<div>
<SectionHeader label="Blocks" count={dependentNodes.length} color="text-amber-400/70" />
{dependentNodes.length > 0 ? (
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
{dependentNodes.map((node) => (
<FlowCard
key={node.id}
node={node}
selected={selectedId === node.id}
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
blocks={signalById.get(node.id)?.blocks ?? 0}
onSelect={onSelect}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
<p className="text-[10px] text-text-muted/40">No dependents</p>
</div>
)}
</div>
</div>
)}
</div>
);
}
'use client';
import type { GraphNode } from '../../lib/graph';
import type { PathWorkspace } from '../../lib/graph-view';
import type { BeadIssue } from '../../lib/types';
/** Props for an individual flow card in the dependency strip. */
interface FlowCardProps {
/** The graph node data for this card. */
node: GraphNode;
/** Whether this card is the currently selected/focused task. */
selected: boolean;
/** Number of issues blocking this node. */
blockedBy: number;
/** Number of issues this node blocks. */
blocks: number;
/** Callback fired when the user clicks this card. */
onSelect: (id: string) => void;
}
/** Props for the DependencyFlowStrip component. */
interface DependencyFlowStripProps {
/** The computed path workspace containing blockers, focus, and dependents. */
workspace: PathWorkspace;
/** ID of the currently selected task, or null. */
selectedId: string | null;
/** Map of issue ID to blocker/blocks counts. */
signalById: Map<string, { blockedBy: number; blocks: number }>;
/** Callback fired when the user selects a card. */
onSelect: (id: string) => void;
}
/**
* Returns the Tailwind background color class for a status dot indicator.
*/
function statusDot(status: BeadIssue['status']): string {
switch (status) {
case 'open':
return 'bg-sky-400';
case 'in_progress':
return 'bg-amber-400';
case 'blocked':
return 'bg-rose-500';
case 'deferred':
return 'bg-slate-400';
case 'closed':
return 'bg-emerald-400';
case 'pinned':
return 'bg-violet-400';
case 'hooked':
return 'bg-orange-400';
default:
return 'bg-zinc-500';
}
}
/**
* A compact card representing a single node in the dependency flow.
* Shows ID, title, status, and blocker/blocks counts.
*/
function FlowCard({ node, selected, blockedBy, blocks, onSelect }: FlowCardProps) {
return (
<button
type="button"
onClick={() => onSelect(node.id)}
className={`workflow-card w-full rounded-xl px-3 py-2.5 text-left transition duration-200 ${selected
? 'workflow-card-selected'
: 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]'
}`}
>
{/* Header: node ID + status dot */}
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[9px] tracking-[0.04em] text-text-muted/80">{node.id}</span>
<span className={`h-2 w-2 shrink-0 rounded-full ${statusDot(node.status)}`} />
</div>
{/* Node title - truncates at 2 lines */}
<p className="mt-1 text-[12px] font-semibold leading-tight text-text-strong line-clamp-2">{node.title}</p>
{/* Dependency signal counts */}
<p className="mt-1 text-[10px] text-text-body">
{blockedBy} blockers &bull; {blocks} dependents
</p>
</button>
);
}
/**
* Renders a section header with a count badge.
*/
function SectionHeader({ label, count, color }: { label: string; count: number; color: string }) {
return (
<div className="flex items-center gap-2 mb-2">
<span className={`text-[10px] font-bold uppercase tracking-[0.15em] ${color}`}>{label}</span>
<span className="rounded-md bg-white/5 px-1.5 py-0.5 text-[9px] font-bold text-text-muted/60">{count}</span>
</div>
);
}
/**
* Renders the dependency flow as three responsive sections stacked vertically:
* Blocked By, Selected/Focus, and Blocks (Dependents).
* Each section uses a responsive wrapping grid so cards never overflow.
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
*/
import { useState } from 'react';
// ... (FlowCardProps, DependencyFlowStripProps, statusDot, FlowCard, SectionHeader definitions remain unchanged)
/**
* Renders the dependency flow as three responsive sections stacked vertically:
* Blocked By, Selected/Focus, and Blocks (Dependents).
* Each section uses a responsive wrapping grid so cards never overflow.
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
*/
export function DependencyFlowStrip({ workspace, selectedId, signalById, onSelect }: DependencyFlowStripProps) {
const [minimized, setMinimized] = useState(false);
// Flatten the multi-hop blocker/dependent arrays for display
const blockerNodes = workspace.blockers.flat();
const dependentNodes = workspace.dependents.flat();
return (
<div className="rounded-2xl border border-white/5 bg-white/[0.01] px-5 py-4 ring-1 ring-white/5 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">
Dependency Flow
</h3>
<button
onClick={() => setMinimized(!minimized)}
className="rounded p-1 hover:bg-white/5 text-text-muted transition-colors"
title={minimized ? "Expand" : "Minimize"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform duration-200 ${minimized ? 'rotate-180' : ''}`}
>
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
</div>
{/* Responsive three-column layout: stacks on mobile, side-by-side on desktop */}
{!minimized && (
<div className="grid gap-4 md:grid-cols-3 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Blocked By section */}
<div>
<SectionHeader label="Blocked By" count={blockerNodes.length} color="text-rose-400/70" />
{blockerNodes.length > 0 ? (
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
{blockerNodes.map((node) => (
<FlowCard
key={node.id}
node={node}
selected={selectedId === node.id}
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
blocks={signalById.get(node.id)?.blocks ?? 0}
onSelect={onSelect}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
<p className="text-[10px] text-text-muted/40">No blockers</p>
</div>
)}
</div>
{/* Selected / Focused task section */}
<div>
<SectionHeader label="Selected" count={workspace.focus ? 1 : 0} color="text-sky-400/70" />
{workspace.focus ? (
<FlowCard
node={workspace.focus}
selected
blockedBy={signalById.get(workspace.focus.id)?.blockedBy ?? 0}
blocks={signalById.get(workspace.focus.id)?.blocks ?? 0}
onSelect={onSelect}
/>
) : (
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
<p className="text-[10px] text-text-muted/40">Select a task</p>
</div>
)}
</div>
{/* Blocks (Dependents) section */}
<div>
<SectionHeader label="Blocks" count={dependentNodes.length} color="text-amber-400/70" />
{dependentNodes.length > 0 ? (
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
{dependentNodes.map((node) => (
<FlowCard
key={node.id}
node={node}
selected={selectedId === node.id}
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
blocks={signalById.get(node.id)?.blocks ?? 0}
onSelect={onSelect}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
<p className="text-[10px] text-text-muted/40">No dependents</p>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -21,6 +21,7 @@ import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
import { TaskDetailsDrawer } from './task-details-drawer';
import { DependencyFlowStrip } from './dependency-flow-strip';
import { GraphNodeCard, type GraphNodeData } from './graph-node-card';
import { OffsetEdge } from './offset-edge';
import { GraphSection } from './graph-section';
import { ProjectScopeControls } from '../shared/project-scope-controls';
import { WorkspaceHero } from '../shared/workspace-hero';
@ -31,6 +32,7 @@ import {
type GraphHopDepth,
analyzeBlockedChain,
detectDependencyCycles,
identifyTransitiveEdges,
} from '../../lib/graph-view';
import { buildBlockedByTree } from '../../lib/kanban';
import { type BeadIssue } from '../../lib/types';
@ -167,6 +169,10 @@ export function DependencyGraphPage({
}),
[issues, hideClosed],
);
const selectableEpics = useMemo(
() => epics.filter((epic) => (!hideClosed ? true : epic.status !== 'closed' && epic.status !== 'tombstone')),
[epics, hideClosed],
);
// --- Derived data: tasks grouped by parent epic ---
const tasksByEpic = useMemo(() => {
@ -226,18 +232,18 @@ export function DependencyGraphPage({
// --- Auto-select first epic if none selected ---
useEffect(() => {
if (epics.length === 0) {
if (selectableEpics.length === 0) {
if (selectedEpicId !== null) {
setSelectedEpicId(null);
}
return;
}
const hasSelectedEpic = selectedEpicId ? epics.some((epic) => epic.id === selectedEpicId) : false;
const hasSelectedEpic = selectedEpicId ? selectableEpics.some((epic) => epic.id === selectedEpicId) : false;
if (!hasSelectedEpic) {
setSelectedEpicId(epics[0].id);
setSelectedEpicId(selectableEpics[0].id);
}
}, [epics, selectedEpicId]);
}, [selectableEpics, selectedEpicId]);
useEffect(() => {
if (requestedTab === 'tasks' || requestedTab === 'dependencies') {
@ -247,9 +253,9 @@ export function DependencyGraphPage({
useEffect(() => {
if (!requestedEpicId) return;
if (!epics.some((epic) => epic.id === requestedEpicId)) return;
if (!selectableEpics.some((epic) => epic.id === requestedEpicId)) return;
setSelectedEpicId(requestedEpicId);
}, [epics, requestedEpicId]);
}, [selectableEpics, requestedEpicId]);
useEffect(() => {
if (!requestedTaskId) {
@ -272,7 +278,7 @@ export function DependencyGraphPage({
}, [issues, selectedId]);
// --- Derived: selected epic and its tasks ---
const selectedEpic = useMemo(() => epics.find((epic) => epic.id === selectedEpicId) ?? null, [epics, selectedEpicId]);
const selectedEpic = useMemo(() => selectableEpics.find((epic) => epic.id === selectedEpicId) ?? null, [selectableEpics, selectedEpicId]);
const projectLevelTasks = useMemo(
() =>
issues
@ -301,8 +307,8 @@ export function DependencyGraphPage({
}
// Last-resort fallback: if there are only epics, render epics as selectable items.
return epics.filter((epic) => (!hideClosed ? true : epic.status !== 'closed'));
}, [epics, hideClosed, projectLevelTasks, selectedEpic, tasksByEpic]);
return selectableEpics;
}, [projectLevelTasks, selectableEpics, selectedEpic, tasksByEpic]);
const selectedEpicHasChildren = useMemo(() => {
if (selectedEpic) {
@ -326,6 +332,9 @@ export function DependencyGraphPage({
// --- Graph model ---
const graphModel = useMemo(() => buildGraphModel(issues, { projectKey: projectRoot }), [issues, projectRoot]);
// --- Transitive edges (redundant blocks) ---
const transitiveEdges = useMemo(() => identifyTransitiveEdges(graphModel), [graphModel]);
// --- Signal map: blocker/blocks counts per issue ---
const signalById = useMemo(() => {
const map = new Map<string, { blockedBy: number; blocks: number }>();
@ -531,7 +540,7 @@ export function DependencyGraphPage({
blocks: signalById.get(issue.id)?.blocks ?? 0,
isActionable: actionableNodeIds.has(issue.id),
isCycleNode: cycleNodeIdSet.has(issue.id),
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
isDimmed: focusId ? !chainNodeIds.has(issue.id) : false,
blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [],
labels: issue.labels,
},
@ -542,6 +551,63 @@ export function DependencyGraphPage({
}));
const visibleIds = new Set(baseNodes.map((node) => node.id));
// Use requestedTaskId from URL as the focus node for upstream/downstream highlighting.
// `selectedId` is local state that tracks click selection for the drawer,
// but it starts as null. The URL `task` param is what the user clicked in the graph
// (set by handleNodeSelect -> router.push). We use requestedTaskId here
// so that clicking a node - which updates the URL - also triggers edge color changes.
const focusId = requestedTaskId;
// --- Compute Upstream / Downstream Focus ---
const upstreamIds = new Set<string>();
const downstreamIds = new Set<string>();
if (focusId && visibleIds.has(focusId)) {
upstreamIds.add(focusId);
downstreamIds.add(focusId);
const outgoing = new Map<string, string[]>();
const incoming = new Map<string, string[]>();
for (const issue of issues) {
for (const dep of issue.dependencies) {
if (dep.type === 'blocks') {
const blocker = dep.target;
const blocked = issue.id;
if (!outgoing.has(blocker)) outgoing.set(blocker, []);
if (!incoming.has(blocked)) incoming.set(blocked, []);
outgoing.get(blocker)!.push(blocked);
incoming.get(blocked)!.push(blocker);
}
}
}
let queue = [focusId];
while (queue.length > 0) {
const curr = queue.shift()!;
for (const b of (incoming.get(curr) || [])) {
if (!upstreamIds.has(b)) {
upstreamIds.add(b);
queue.push(b);
}
}
}
queue = [focusId];
while (queue.length > 0) {
const curr = queue.shift()!;
for (const b of (outgoing.get(curr) || [])) {
if (!downstreamIds.has(b)) {
downstreamIds.add(b);
queue.push(b);
}
}
}
}
const graphEdges: Edge[] = [];
// Search ALL issues for blocking edges between visible nodes.
@ -550,25 +616,87 @@ export function DependencyGraphPage({
for (const issue of issues) {
for (const dep of issue.dependencies) {
// Both endpoints must be visible in the graph
if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue;
if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue;
// Only show blocking edges (skip parent, relates_to, etc.)
if (dep.type !== 'blocks') continue;
// Avoid self-loops
if (issue.id === dep.target) continue;
const edgeId = `${dep.target}:blocks:${issue.id}`;
const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false;
const edgeId = `${dep.target}:blocks:${issue.id}`;
const sourceId = dep.target;
const targetId = issue.id;
const isUpstreamOfFocus = focusId ? upstreamIds.has(sourceId) && upstreamIds.has(targetId) : false;
const isDownstreamOfFocus = focusId ? downstreamIds.has(sourceId) && downstreamIds.has(targetId) : false;
const isDirectlyFocused = focusId ? sourceId === focusId || targetId === focusId : false;
let isUnrelated = false;
if (focusId) {
isUnrelated = !isUpstreamOfFocus && !isDownstreamOfFocus && !isDirectlyFocused;
}
const sourceNode = issues.find(i => i.id === sourceId);
const sourceStatus = sourceNode?.status || 'open';
const isTransitive = transitiveEdges.has(edgeId);
let stroke = '#3b82f6';
let strokeBg = 'rgba(59, 130, 246, 0.25)';
let dashArray: string | undefined = undefined;
let opacity = 0.8;
const isFocusedPath = isUpstreamOfFocus || isDownstreamOfFocus || isDirectlyFocused;
const isAnimated = isFocusedPath || sourceStatus === 'in_progress';
// Base Status Colors
if (sourceStatus === 'in_progress') {
stroke = '#fbbf24'; // Bright Amber
strokeBg = 'rgba(251, 191, 36, 0.25)';
} else if (sourceStatus === 'blocked') {
stroke = '#f43f5e'; // Rose/Red for deep block
strokeBg = 'rgba(244, 63, 94, 0.25)';
} else {
stroke = '#3b82f6'; // Blue for open/ready
strokeBg = 'rgba(59, 130, 246, 0.25)';
}
// Selection Focus Overrides
if (focusId) {
if (isUnrelated) {
stroke = '#1e293b'; // Super dim unrelated edges
strokeBg = 'transparent';
opacity = 0.15;
} else if (isUpstreamOfFocus || (isDirectlyFocused && targetId === focusId)) {
stroke = '#f59e0b'; // Amber -- "I am blocking you"
strokeBg = 'rgba(245, 158, 11, 0.35)';
opacity = 1;
} else if (isDownstreamOfFocus || (isDirectlyFocused && sourceId === focusId)) {
stroke = '#0ea5e9'; // Cyan -- "you are blocking me"
strokeBg = 'rgba(14, 165, 233, 0.35)';
opacity = 1;
}
}
// Transitive Styling
if (isTransitive) {
dashArray = '4 4';
if (!focusId || isUnrelated) {
stroke = '#334155';
strokeBg = 'rgba(51, 65, 85, 0.3)';
opacity = 0.4;
} else {
opacity = 0.6; // Keep focused color but make dashed/transparent
}
}
graphEdges.push({
id: edgeId,
source: dep.target,
target: issue.id,
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
animated: linkedToSelection,
source: sourceId,
target: targetId,
className: isFocusedPath ? 'workflow-edge-selected' : 'workflow-edge-muted',
animated: isAnimated,
label: 'BLOCKS',
labelStyle: {
fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1',
fill: isFocusedPath ? '#e2e8f0' : '#cbd5e1',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.08em',
@ -577,25 +705,50 @@ export function DependencyGraphPage({
labelBgBorderRadius: 999,
labelBgStyle: {
fill: 'rgba(2, 6, 23, 0.92)',
stroke: linkedToSelection ? 'rgba(125, 211, 252, 0.35)' : 'rgba(251, 191, 36, 0.25)',
stroke: strokeBg,
strokeWidth: 1,
},
style: {
stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24',
strokeWidth: linkedToSelection ? 2.8 : 2.1,
opacity: linkedToSelection ? 1 : 0.78,
stroke,
strokeWidth: isFocusedPath ? 2.8 : 2.1,
opacity,
strokeDasharray: dashArray,
},
markerEnd: { type: MarkerType.ArrowClosed, color: linkedToSelection ? '#7dd3fc' : '#fbbf24', width: 14, height: 14 },
markerEnd: { type: MarkerType.ArrowClosed, color: stroke, width: 14, height: 14 },
});
}
}
// --- Apply Offsets to Edge Data ---
// Count how many edges share the same source and target, or just
// group them by axis line to separate them visually.
const edgeGroups = new Map<string, Edge[]>();
for (const edge of graphEdges) {
// Create a normalized key roughly defining the segment direction
const key = [edge.source, edge.target].sort().join('-');
if (!edgeGroups.has(key)) edgeGroups.set(key, []);
edgeGroups.get(key)!.push(edge);
}
// Assign offsets based on index in their shared group.
for (const [unused_, groupEdges] of edgeGroups) {
if (groupEdges.length <= 1) continue;
const step = 8; // 8px offset per line
const totalSpread = (groupEdges.length - 1) * step;
let currentOffset = -(totalSpread / 2);
for (const edge of groupEdges) {
edge.data = { ...edge.data, offset: currentOffset };
currentOffset += step;
}
}
return {
nodes: layoutDagre(baseNodes, graphEdges),
edges: graphEdges,
};
}, [
hideClosed, issues, selectedEpicTasks, selectedId,
transitiveEdges, hideClosed, issues, selectedEpicTasks, requestedTaskId,
signalById, actionableNodeIds, cycleNodeIdSet,
chainNodeIds, blockerTooltipMap, externalBlockerNames,
]);
@ -607,6 +760,13 @@ export function DependencyGraphPage({
[],
);
const edgeTypes = useMemo(
() => ({
offset: OffsetEdge,
}),
[]
);
// --- Handle node click in the graph (also opens detail drawer) ---
const handleFlowNodeClick: NodeMouseHandler = useCallback((_, node) => {
setSelectedId(node.id);
@ -714,7 +874,7 @@ export function DependencyGraphPage({
{/* Epic chip strip - shows titles, not just IDs */}
<div className="flex-1 min-w-0">
<EpicChipStrip
epics={epics}
epics={selectableEpics}
selectedEpicId={selectedEpicId}
beadCounts={beadCounts}
onSelect={setSelectedEpicId}
@ -826,7 +986,7 @@ export function DependencyGraphPage({
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">1) Select Epic</h2>
<div className="mt-4">
<EpicChipStrip
epics={epics}
epics={selectableEpics}
selectedEpicId={selectedEpicId}
beadCounts={beadCounts}
onSelect={setSelectedEpicId}
@ -893,6 +1053,7 @@ export function DependencyGraphPage({
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
onNodeClick={handleFlowNodeClick}
blockerAnalysis={blockerAnalysis}

View file

@ -1,115 +1,118 @@
'use client';
import {
Background,
ReactFlow,
type Edge,
type Node,
type NodeMouseHandler,
type NodeTypes,
} from '@xyflow/react';
import type { BlockedChainAnalysis } from '../../lib/graph-view';
import type { GraphNodeData } from './graph-node-card';
/** Props for the GraphSection component. */
interface GraphSectionProps {
/** ReactFlow nodes with layout positions applied. */
nodes: Node<GraphNodeData>[];
/** ReactFlow edges connecting the nodes. */
edges: Edge[];
/** Map of custom node type names to their React components. */
nodeTypes: NodeTypes;
/** Default edge rendering options. */
defaultEdgeOptions: {
type: 'smoothstep';
zIndex: number;
interactionWidth: number;
};
/** Callback fired when a node is clicked in the graph. */
onNodeClick: NodeMouseHandler;
/** Optional blocker summary for the currently selected task. */
blockerAnalysis?: BlockedChainAnalysis | null;
/** Whether closed items are hidden from the graph workspace. */
hideClosed?: boolean;
}
/**
* Renders the ReactFlow graph with status-lane layout.
* Shows a compact legend and full graph viewport.
* Nodes are positioned in columns by status: Done | In Progress | Ready | Blocked.
*/
export function GraphSection({
nodes,
edges,
nodeTypes,
defaultEdgeOptions,
onNodeClick,
blockerAnalysis,
hideClosed = false,
}: GraphSectionProps) {
return (
<div className="flex flex-col gap-3">
{/* Compact legend + tip */}
<div className="workflow-graph-legend flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-3 py-2">
<p className="text-[10px] text-text-muted/60">
<span className="font-bold uppercase tracking-[0.15em]">Legend</span>
{' '}
{!hideClosed ? (
<>
<span className="text-emerald-400">Done</span>
{' \u2192 '}
</>
) : null}
<span className="text-amber-400">In Progress</span>
{' \u2192 '}
<span className="text-cyan-400">Ready</span>
{' \u2192 '}
<span className="text-rose-400">Blocked</span>
</p>
<p className="text-[10px] text-text-muted/40">
Click a task to see details &bull;{' '}
<span className="inline-block h-1 w-4 rounded bg-amber-400 align-middle" /> = blocks
</p>
{blockerAnalysis ? (
<p className="text-[10px] text-text-muted/60">
Open blockers: {blockerAnalysis.openBlockerCount}
{' | '}
In progress blockers: {blockerAnalysis.inProgressBlockerCount}
</p>
) : null}
<p className="w-full text-[10px] text-text-muted/55 md:w-auto md:max-w-[26rem]">
<span className="font-semibold text-text-muted/75">Read left to right:</span>{' '}
Left = blockers, middle = selected task, Right = work unblocked by this task.
</p>
</div>
{/* ReactFlow graph viewport */}
<div className="relative h-[60vh] min-h-[24rem] md:min-h-[35rem] overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-secondary)] shadow-inner">
<ReactFlow
className="workflow-graph-flow"
defaultEdgeOptions={defaultEdgeOptions}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={1.5}
translateExtent={[
[-500, -500],
[3000, 2500],
]}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
onlyRenderVisibleElements
onNodeClick={onNodeClick}
>
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
</ReactFlow>
</div>
</div>
);
}
'use client';
import {
Background,
ReactFlow,
type Edge,
type Node,
type NodeMouseHandler,
type NodeTypes,
} from '@xyflow/react';
import type { BlockedChainAnalysis } from '../../lib/graph-view';
import type { GraphNodeData } from './graph-node-card';
/** Props for the GraphSection component. */
interface GraphSectionProps {
/** ReactFlow nodes with layout positions applied. */
nodes: Node<GraphNodeData>[];
/** ReactFlow edges connecting the nodes. */
edges: Edge[];
/** Map of custom node type names to their React components. */
nodeTypes: NodeTypes;
edgeTypes?: any;
/** Default edge rendering options. */
defaultEdgeOptions: {
type: 'smoothstep';
zIndex: number;
interactionWidth: number;
};
/** Callback fired when a node is clicked in the graph. */
onNodeClick: NodeMouseHandler;
/** Optional blocker summary for the currently selected task. */
blockerAnalysis?: BlockedChainAnalysis | null;
/** Whether closed items are hidden from the graph workspace. */
hideClosed?: boolean;
}
/**
* Renders the ReactFlow graph with status-lane layout.
* Shows a compact legend and full graph viewport.
* Nodes are positioned in columns by status: Done | In Progress | Ready | Blocked.
*/
export function GraphSection({
nodes,
edges,
nodeTypes,
edgeTypes,
defaultEdgeOptions,
onNodeClick,
blockerAnalysis,
hideClosed = false,
}: GraphSectionProps) {
return (
<div className="flex flex-col gap-3">
{/* Compact legend + tip */}
<div className="workflow-graph-legend flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-3 py-2">
<p className="text-[10px] text-text-muted/60">
<span className="font-bold uppercase tracking-[0.15em]">Legend</span>
{' '}
{!hideClosed ? (
<>
<span className="text-emerald-400">Done</span>
{' \u2192 '}
</>
) : null}
<span className="text-amber-400">In Progress</span>
{' \u2192 '}
<span className="text-cyan-400">Ready</span>
{' \u2192 '}
<span className="text-rose-400">Blocked</span>
</p>
<p className="text-[10px] text-text-muted/40">
Click a task to see details &bull;{' '}
<span className="inline-block h-1 w-4 rounded bg-amber-400 align-middle" /> = blocks
</p>
{blockerAnalysis ? (
<p className="text-[10px] text-text-muted/60">
Open blockers: {blockerAnalysis.openBlockerCount}
{' | '}
In progress blockers: {blockerAnalysis.inProgressBlockerCount}
</p>
) : null}
<p className="w-full text-[10px] text-text-muted/55 md:w-auto md:max-w-[26rem]">
<span className="font-semibold text-text-muted/75">Read left to right:</span>{' '}
Left = blockers, middle = selected task, Right = work unblocked by this task.
</p>
</div>
{/* ReactFlow graph viewport */}
<div className="relative h-[60vh] min-h-[24rem] md:min-h-[35rem] overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-secondary)] shadow-inner">
<ReactFlow
className="workflow-graph-flow"
defaultEdgeOptions={defaultEdgeOptions}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={1.5}
translateExtent={[
[-500, -500],
[3000, 2500],
]}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
onlyRenderVisibleElements
onNodeClick={onNodeClick}
>
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
</ReactFlow>
</div>
</div>
);
}

View file

@ -1,71 +1,71 @@
'use client';
import type { BeadIssue } from '../../lib/types';
import { WorkflowGraph } from '../shared/workflow-graph';
import type { GraphTabType } from '../../hooks/use-url-state';
interface GraphViewProps {
beads: BeadIssue[];
selectedId?: string;
onSelect?: (id: string) => void;
graphTab: GraphTabType;
onGraphTabChange: (tab: GraphTabType) => void;
hideClosed?: boolean;
}
export function GraphView({
beads,
selectedId,
onSelect,
graphTab,
onGraphTabChange,
hideClosed = false,
}: GraphViewProps) {
return (
<div className="flex h-full flex-col bg-[var(--surface-secondary)]">
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-4 py-2.5">
<div className="flex items-center gap-3">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">
Graph View
</p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onGraphTabChange('flow')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-tertiary)] ${
graphTab === 'flow'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
}`}
>
Flow
</button>
<button
type="button"
onClick={() => onGraphTabChange('overview')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-tertiary)] ${
graphTab === 'overview'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
}`}
>
Overview
</button>
</div>
</div>
<span className="text-[10px] text-text-muted/50">
{beads.length} beads
</span>
</div>
<div className="min-h-0 flex-1">
<WorkflowGraph
beads={beads}
selectedId={selectedId}
onSelect={onSelect}
hideClosed={graphTab === 'flow' ? hideClosed : false}
className="h-full"
/>
</div>
</div>
);
}
'use client';
import type { BeadIssue } from '../../lib/types';
import { WorkflowGraph } from '../shared/workflow-graph';
import type { GraphTabType } from '../../hooks/use-url-state';
interface GraphViewProps {
beads: BeadIssue[];
selectedId?: string;
onSelect?: (id: string) => void;
graphTab: GraphTabType;
onGraphTabChange: (tab: GraphTabType) => void;
hideClosed?: boolean;
}
export function GraphView({
beads,
selectedId,
onSelect,
graphTab,
onGraphTabChange,
hideClosed = false,
}: GraphViewProps) {
return (
<div className="flex h-full flex-col bg-[var(--surface-secondary)]">
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-4 py-2.5">
<div className="flex items-center gap-3">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">
Graph View
</p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onGraphTabChange('flow')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-tertiary)] ${
graphTab === 'flow'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
}`}
>
Flow
</button>
<button
type="button"
onClick={() => onGraphTabChange('overview')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-tertiary)] ${
graphTab === 'overview'
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
}`}
>
Overview
</button>
</div>
</div>
<span className="text-[10px] text-text-muted/50">
{beads.length} beads
</span>
</div>
<div className="min-h-0 flex-1">
<WorkflowGraph
beads={beads}
selectedId={selectedId}
onSelect={onSelect}
hideClosed={graphTab === 'flow' ? hideClosed : false}
className="h-full"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
import React from 'react';
import { BaseEdge, EdgeProps, getSmoothStepPath, EdgeLabelRenderer } from '@xyflow/react';
export function OffsetEdge(props: EdgeProps) {
const {
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
data,
label,
labelStyle,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
animated,
} = props;
// We can pass `offset` via the edge data. Positive or negative pixels.
const offset = data?.offset as number | undefined ?? 0;
// Apply offset to the Y axis for Left/Right layouts (horizontal edges)
// or to the X axis for Top/Bottom layouts (vertical edges).
// Assuming 'sourcePosition' dictates the primary flow direction.
let sx = sourceX;
let sy = sourceY;
let tx = targetX;
let ty = targetY;
if (sourcePosition === 'right' || sourcePosition === 'left') {
// Horizontal flow, offset the vertical axis (Y)
sy += offset;
ty += offset;
} else {
// Vertical flow, offset the horizontal axis (X)
sx += offset;
tx += offset;
}
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX: sx,
sourceY: sy,
sourcePosition,
targetX: tx,
targetY: ty,
targetPosition,
// Optional: reduce the corner radius slightly for tighter clusters
borderRadius: 8,
});
return (
<>
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
className={animated ? "animated-edge" : ""}
style={{ ...style, strokeDasharray: animated ? "5, 5" : "none" }}
/>
{label && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
...(labelBgStyle as React.CSSProperties),
padding: Array.isArray(labelBgPadding) ? `${labelBgPadding[0]}px ${labelBgPadding[1]}px` : labelBgPadding,
borderRadius: labelBgBorderRadius,
}}
className="nodrag nopan"
>
<div style={labelStyle as React.CSSProperties}>{label}</div>
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View file

@ -5,9 +5,10 @@ import { Filter, UserPlus } from 'lucide-react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useUrlState, buildUrlParams } from '../../hooks/use-url-state';
import type { BeadIssue } from '../../lib/types';
import type { GraphHopDepth } from '../../lib/graph-view';
import { WorkflowGraph } from '../shared/workflow-graph';
import type { BeadIssue } from '../../lib/types';
import type { GraphHopDepth } from '../../lib/graph-view';
import { collectEpicDescendantIds } from '../../lib/epic-graph';
import { WorkflowGraph } from '../shared/workflow-graph';
import { WorkflowTabs, type WorkflowTab } from './workflow-tabs';
import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
import { useArchetypes } from '../../hooks/use-archetypes';
@ -73,14 +74,11 @@ export function SmartDag({
const [blockingOnly, setBlockingOnly] = useState(false);
const [sortReadyFirst, setSortReadyFirst] = useState(true);
const displayBeads = useMemo(() => {
if (!epicId) return issues;
return issues.filter(issue => {
if (issue.issue_type === 'epic') return false;
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
}, [issues, epicId]);
const displayBeads = useMemo(() => {
if (!epicId) return issues;
const descendantIds = collectEpicDescendantIds(issues, epicId);
return issues.filter((issue) => descendantIds.has(issue.id));
}, [issues, epicId]);
const {
signalById,

View file

@ -1,117 +1,117 @@
'use client';
import { useEffect, useRef } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { BlockedTreeNode } from '../../lib/kanban';
import { KanbanDetail } from '../kanban/kanban-detail';
/** Props for the TaskDetailsDrawer component. */
interface TaskDetailsDrawerProps {
/** The issue to display, or null if nothing is selected. */
issue: BeadIssue | null;
/** Whether the drawer is open (visible). */
open: boolean;
/** Callback fired when the user closes the drawer. */
onClose: () => void;
/** Project root for mutation requests. */
projectRoot?: string;
/** Whether editing is enabled for the drawer. */
editable?: boolean;
/** Callback fired after successful save. */
onIssueUpdated?: (issueId: string) => Promise<void> | void;
/** Tree of blocked issues (incoming). */
blockedTree?: { total: number; nodes: BlockedTreeNode[] };
/** List of issues blocked by this one (outgoing). */
outgoingBlocks?: { id: string; title: string; status: string }[];
/** Callback when a blocked/blocking issue is clicked. */
onSelectBlockedIssue?: (issueId: string) => void;
}
/**
* A slide-in drawer panel from the right side that shows full task details.
* Opens when a task is selected, closes via the X button or clicking the backdrop.
* Uses CSS translate for the slide animation.
*/
export function TaskDetailsDrawer({
issue,
open,
onClose,
projectRoot,
editable = true,
onIssueUpdated,
blockedTree,
outgoingBlocks,
onSelectBlockedIssue
}: TaskDetailsDrawerProps) {
// Reference for the drawer panel to manage focus trapping
const drawerRef = useRef<HTMLDivElement>(null);
// Close drawer on Escape key press
useEffect(() => {
if (!open) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
return (
<>
{/* Backdrop overlay - click to close */}
<div
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-300 ${open ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer panel - slides in from right */}
<div
ref={drawerRef}
className={`fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-white/10 bg-[#0b0c10]/95 backdrop-blur-xl shadow-[-32px_0_64px_rgba(0,0,0,0.5)] transition-transform duration-300 ease-out ${open ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Drawer header with close button */}
<div className="flex items-center justify-between border-b border-white/5 bg-white/[0.02] px-6 py-4">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">Task Details</p>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold text-text-body transition-all hover:bg-white/10 active:scale-95"
>
Close
</button>
</div>
{/* Scrollable content area */}
<div className="flex-1 overflow-y-auto overscroll-contain p-6 custom-scrollbar">
{issue ? (
<KanbanDetail
issue={issue}
framed={false}
projectRoot={projectRoot}
editable={editable}
onIssueUpdated={onIssueUpdated}
blockedTree={blockedTree}
outgoingBlocks={outgoingBlocks}
onSelectBlockedIssue={onSelectBlockedIssue}
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">
Select a task to view details
</p>
</div>
)}
</div>
</div>
</>
);
}
'use client';
import { useEffect, useRef } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { BlockedTreeNode } from '../../lib/kanban';
import { KanbanDetail } from '../kanban/kanban-detail';
/** Props for the TaskDetailsDrawer component. */
interface TaskDetailsDrawerProps {
/** The issue to display, or null if nothing is selected. */
issue: BeadIssue | null;
/** Whether the drawer is open (visible). */
open: boolean;
/** Callback fired when the user closes the drawer. */
onClose: () => void;
/** Project root for mutation requests. */
projectRoot?: string;
/** Whether editing is enabled for the drawer. */
editable?: boolean;
/** Callback fired after successful save. */
onIssueUpdated?: (issueId: string) => Promise<void> | void;
/** Tree of blocked issues (incoming). */
blockedTree?: { total: number; nodes: BlockedTreeNode[] };
/** List of issues blocked by this one (outgoing). */
outgoingBlocks?: { id: string; title: string; status: string }[];
/** Callback when a blocked/blocking issue is clicked. */
onSelectBlockedIssue?: (issueId: string) => void;
}
/**
* A slide-in drawer panel from the right side that shows full task details.
* Opens when a task is selected, closes via the X button or clicking the backdrop.
* Uses CSS translate for the slide animation.
*/
export function TaskDetailsDrawer({
issue,
open,
onClose,
projectRoot,
editable = true,
onIssueUpdated,
blockedTree,
outgoingBlocks,
onSelectBlockedIssue
}: TaskDetailsDrawerProps) {
// Reference for the drawer panel to manage focus trapping
const drawerRef = useRef<HTMLDivElement>(null);
// Close drawer on Escape key press
useEffect(() => {
if (!open) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
return (
<>
{/* Backdrop overlay - click to close */}
<div
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-300 ${open ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer panel - slides in from right */}
<div
ref={drawerRef}
className={`fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-white/10 bg-[#0b0c10]/95 backdrop-blur-xl shadow-[-32px_0_64px_rgba(0,0,0,0.5)] transition-transform duration-300 ease-out ${open ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Drawer header with close button */}
<div className="flex items-center justify-between border-b border-white/5 bg-white/[0.02] px-6 py-4">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">Task Details</p>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold text-text-body transition-all hover:bg-white/10 active:scale-95"
>
Close
</button>
</div>
{/* Scrollable content area */}
<div className="flex-1 overflow-y-auto overscroll-contain p-6 custom-scrollbar">
{issue ? (
<KanbanDetail
issue={issue}
framed={false}
projectRoot={projectRoot}
editable={editable}
onIssueUpdated={onIssueUpdated}
blockedTree={blockedTree}
outgoingBlocks={outgoingBlocks}
onSelectBlockedIssue={onSelectBlockedIssue}
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">
Select a task to view details
</p>
</div>
)}
</div>
</div>
</>
);
}

View file

@ -1,47 +1,47 @@
'use client';
/** The two available view tabs in the Workflow Explorer. */
export type WorkflowTab = 'tasks' | 'dependencies';
/** Props for the WorkflowTabs component. */
interface WorkflowTabsProps {
/** The currently active tab. */
activeTab: WorkflowTab;
/** Callback fired when the user switches tabs. */
onTabChange: (tab: WorkflowTab) => void;
}
/** Tab label and key pairs for rendering. */
const TAB_OPTIONS: { key: WorkflowTab; label: string }[] = [
{ key: 'tasks', label: 'Tasks' },
{ key: 'dependencies', label: 'Dependencies' },
];
/**
* A two-tab switcher for toggling between the Tasks view and Dependencies view.
* Uses a pill-style indicator that slides to the active tab.
*/
export function WorkflowTabs({ activeTab, onTabChange }: WorkflowTabsProps) {
return (
<div className="inline-flex items-center gap-1 rounded-xl border border-white/8 bg-white/[0.02] p-1">
{TAB_OPTIONS.map((tab) => {
// Determine if this tab is currently active
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={`rounded-lg px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all duration-200 ${isActive
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
}`}
>
{tab.label}
</button>
);
})}
</div>
);
}
'use client';
/** The two available view tabs in the Workflow Explorer. */
export type WorkflowTab = 'tasks' | 'dependencies';
/** Props for the WorkflowTabs component. */
interface WorkflowTabsProps {
/** The currently active tab. */
activeTab: WorkflowTab;
/** Callback fired when the user switches tabs. */
onTabChange: (tab: WorkflowTab) => void;
}
/** Tab label and key pairs for rendering. */
const TAB_OPTIONS: { key: WorkflowTab; label: string }[] = [
{ key: 'tasks', label: 'Tasks' },
{ key: 'dependencies', label: 'Dependencies' },
];
/**
* A two-tab switcher for toggling between the Tasks view and Dependencies view.
* Uses a pill-style indicator that slides to the active tab.
*/
export function WorkflowTabs({ activeTab, onTabChange }: WorkflowTabsProps) {
return (
<div className="inline-flex items-center gap-1 rounded-xl border border-white/8 bg-white/[0.02] p-1">
{TAB_OPTIONS.map((tab) => {
// Determine if this tab is currently active
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={`rounded-lg px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all duration-200 ${isActive
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
}`}
>
{tab.label}
</button>
);
})}
</div>
);
}

View file

@ -1,187 +1,187 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import type { DragEvent } from 'react';
import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { KanbanCard } from './kanban-card';
interface KanbanBoardProps {
columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
parentEpicByIssueId: Map<string, { id: string; title: string }>;
graphBaseHref: string;
showClosed: boolean;
selectedIssueId: string | null;
pendingIssueIds: Set<string>;
activeStatus: KanbanStatus | null;
onActivateStatus: (status: KanbanStatus | null) => void;
onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void;
onSelect: (issue: BeadIssue) => void;
}
const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = {
ready: { label: 'Ready', dot: 'bg-sky-300' },
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
closed: { label: 'Done', dot: 'bg-emerald-300' },
};
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
ready:
'border-t-2 border-sky-400/40 bg-slate-900/50',
in_progress:
'border-t-2 border-amber-400/40 bg-slate-900/50',
blocked:
'border-t-2 border-rose-400/40 bg-slate-900/50',
closed:
'border-t-2 border-emerald-400/40 bg-slate-900/50',
};
export function KanbanBoard({
columns,
parentEpicByIssueId,
graphBaseHref,
showClosed,
selectedIssueId,
pendingIssueIds,
activeStatus,
onActivateStatus,
onMoveIssue,
onSelect,
}: KanbanBoardProps) {
const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]);
const visibleStatuses = KANBAN_STATUSES.filter((status) => status !== 'closed' || showClosed);
const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue]));
const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => {
onActivateStatus(status);
onSelect(issue);
};
const onDragStart = (issue: BeadIssue, sourceLane: KanbanStatus, event: DragEvent<HTMLElement>) => {
event.dataTransfer.setData('application/x-bead-id', issue.id);
event.dataTransfer.setData('application/x-bead-lane', sourceLane);
event.dataTransfer.effectAllowed = 'move';
};
const onDropLane = (targetStatus: KanbanStatus, event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const issueId = event.dataTransfer.getData('application/x-bead-id');
const sourceStatus = event.dataTransfer.getData('application/x-bead-lane') as KanbanStatus;
if (!issueId || !sourceStatus || sourceStatus === targetStatus) {
return;
}
const issue = issueLookup.get(issueId);
if (!issue) {
return;
}
onMoveIssue(issue, targetStatus);
};
return (
<section className="grid min-h-[58vh] gap-2.5">
{visibleStatuses.map((status) => (
<div
key={status}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => onDropLane(status, event)}
className={`rounded-2xl border border-white/[0.04] ${STATUS_COLUMN_CLASS[status]} p-2.5 transition shadow-[0_24px_52px_-20px_rgba(0,0,0,0.82),0_10px_26px_-14px_rgba(0,0,0,0.75),inset_0_1px_0_rgba(255,255,255,0.08)] ${
activeStatus === status
? 'shadow-[0_30px_62px_-18px_rgba(0,0,0,0.86),0_0_0_1px_rgba(125,211,252,0.14)]'
: 'opacity-95'
}`}
>
<div className="flex items-center gap-2">
<button
type="button"
aria-expanded={activeStatus === status}
onClick={() => {
onActivateStatus(status);
const firstIssue = columns[status][0];
if (firstIssue) {
onSelect(firstIssue);
}
}}
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
>
<strong className="ui-text inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
{STATUS_META[status].label}
</strong>
<span className="system-data text-xs text-text-muted">{columns[status].length}</span>
</button>
{activeStatus === status ? (
<button
type="button"
aria-label={`Minimize ${STATUS_META[status].label} lane`}
onClick={() => onActivateStatus(null)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border-soft bg-surface-muted/60 text-sm text-text-muted hover:border-border-strong hover:text-text-body"
>
-
</button>
) : null}
</div>
{activeStatus === status ? (
<div className="mt-2 grid max-h-[50vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard
key={issue.id}
issue={issue}
issues={allIssues}
parentEpic={parentEpicByIssueId.get(issue.id) ?? null}
graphBaseHref={graphBaseHref}
pending={pendingIssueIds.has(issue.id)}
selected={selectedIssueId === issue.id}
draggable={!pendingIssueIds.has(issue.id)}
onNativeDragStart={(dragIssue, event) => onDragStart(dragIssue, status, event)}
onSelect={onSelect}
/>
))}
</AnimatePresence>
{columns[status].length === 0 ? (
<div className="flex h-24 w-full items-center justify-center rounded-xl border border-dashed border-border-soft/80 bg-surface/35 text-xs text-text-muted">
No beads
</div>
) : null}
</div>
) : (
<div className="mt-2 flex flex-wrap gap-1.5">
{columns[status].slice(0, 6).map((issue) => (
<button
key={issue.id}
type="button"
onClick={() => handleExpandAndSelect(status, issue)}
className="max-w-full rounded-lg border border-border-soft bg-gradient-to-b from-surface-muted/50 to-surface-muted/70 px-2 py-1 text-left hover:border-border-strong hover:from-surface-raised/70 hover:to-surface-raised/90 shadow-[0_1px_3px_rgba(0,0,0,0.1)]"
title={issue.title}
>
<div className="system-data text-[10px] text-text-muted">{issue.id}</div>
<div className="ui-text line-clamp-1 text-sm font-medium text-text-body">{issue.title}</div>
</button>
))}
{columns[status].length > 6 ? (
<button
type="button"
onClick={() => onActivateStatus(status)}
className="rounded-lg border border-border-soft bg-surface/50 px-2 py-1 text-xs text-text-muted hover:bg-surface-muted/70"
>
+{columns[status].length - 6} more
</button>
) : null}
{columns[status].length === 0 ? (
<span className="rounded-lg border border-dashed border-border-soft/80 bg-surface/30 px-2 py-1 text-xs text-text-muted">
No beads
</span>
) : null}
</div>
)}
</div>
))}
</section>
);
}
'use client';
import { AnimatePresence } from 'framer-motion';
import type { DragEvent } from 'react';
import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { KanbanCard } from './kanban-card';
interface KanbanBoardProps {
columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
parentEpicByIssueId: Map<string, { id: string; title: string }>;
graphBaseHref: string;
showClosed: boolean;
selectedIssueId: string | null;
pendingIssueIds: Set<string>;
activeStatus: KanbanStatus | null;
onActivateStatus: (status: KanbanStatus | null) => void;
onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void;
onSelect: (issue: BeadIssue) => void;
}
const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = {
ready: { label: 'Ready', dot: 'bg-sky-300' },
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
closed: { label: 'Done', dot: 'bg-emerald-300' },
};
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
ready:
'border-t-2 border-sky-400/40 bg-slate-900/50',
in_progress:
'border-t-2 border-amber-400/40 bg-slate-900/50',
blocked:
'border-t-2 border-rose-400/40 bg-slate-900/50',
closed:
'border-t-2 border-emerald-400/40 bg-slate-900/50',
};
export function KanbanBoard({
columns,
parentEpicByIssueId,
graphBaseHref,
showClosed,
selectedIssueId,
pendingIssueIds,
activeStatus,
onActivateStatus,
onMoveIssue,
onSelect,
}: KanbanBoardProps) {
const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]);
const visibleStatuses = KANBAN_STATUSES.filter((status) => status !== 'closed' || showClosed);
const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue]));
const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => {
onActivateStatus(status);
onSelect(issue);
};
const onDragStart = (issue: BeadIssue, sourceLane: KanbanStatus, event: DragEvent<HTMLElement>) => {
event.dataTransfer.setData('application/x-bead-id', issue.id);
event.dataTransfer.setData('application/x-bead-lane', sourceLane);
event.dataTransfer.effectAllowed = 'move';
};
const onDropLane = (targetStatus: KanbanStatus, event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const issueId = event.dataTransfer.getData('application/x-bead-id');
const sourceStatus = event.dataTransfer.getData('application/x-bead-lane') as KanbanStatus;
if (!issueId || !sourceStatus || sourceStatus === targetStatus) {
return;
}
const issue = issueLookup.get(issueId);
if (!issue) {
return;
}
onMoveIssue(issue, targetStatus);
};
return (
<section className="grid min-h-[58vh] gap-2.5">
{visibleStatuses.map((status) => (
<div
key={status}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => onDropLane(status, event)}
className={`rounded-2xl border border-white/[0.04] ${STATUS_COLUMN_CLASS[status]} p-2.5 transition shadow-[0_24px_52px_-20px_rgba(0,0,0,0.82),0_10px_26px_-14px_rgba(0,0,0,0.75),inset_0_1px_0_rgba(255,255,255,0.08)] ${
activeStatus === status
? 'shadow-[0_30px_62px_-18px_rgba(0,0,0,0.86),0_0_0_1px_rgba(125,211,252,0.14)]'
: 'opacity-95'
}`}
>
<div className="flex items-center gap-2">
<button
type="button"
aria-expanded={activeStatus === status}
onClick={() => {
onActivateStatus(status);
const firstIssue = columns[status][0];
if (firstIssue) {
onSelect(firstIssue);
}
}}
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
>
<strong className="ui-text inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
{STATUS_META[status].label}
</strong>
<span className="system-data text-xs text-text-muted">{columns[status].length}</span>
</button>
{activeStatus === status ? (
<button
type="button"
aria-label={`Minimize ${STATUS_META[status].label} lane`}
onClick={() => onActivateStatus(null)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border-soft bg-surface-muted/60 text-sm text-text-muted hover:border-border-strong hover:text-text-body"
>
-
</button>
) : null}
</div>
{activeStatus === status ? (
<div className="mt-2 grid max-h-[50vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard
key={issue.id}
issue={issue}
issues={allIssues}
parentEpic={parentEpicByIssueId.get(issue.id) ?? null}
graphBaseHref={graphBaseHref}
pending={pendingIssueIds.has(issue.id)}
selected={selectedIssueId === issue.id}
draggable={!pendingIssueIds.has(issue.id)}
onNativeDragStart={(dragIssue, event) => onDragStart(dragIssue, status, event)}
onSelect={onSelect}
/>
))}
</AnimatePresence>
{columns[status].length === 0 ? (
<div className="flex h-24 w-full items-center justify-center rounded-xl border border-dashed border-border-soft/80 bg-surface/35 text-xs text-text-muted">
No beads
</div>
) : null}
</div>
) : (
<div className="mt-2 flex flex-wrap gap-1.5">
{columns[status].slice(0, 6).map((issue) => (
<button
key={issue.id}
type="button"
onClick={() => handleExpandAndSelect(status, issue)}
className="max-w-full rounded-lg border border-border-soft bg-gradient-to-b from-surface-muted/50 to-surface-muted/70 px-2 py-1 text-left hover:border-border-strong hover:from-surface-raised/70 hover:to-surface-raised/90 shadow-[0_1px_3px_rgba(0,0,0,0.1)]"
title={issue.title}
>
<div className="system-data text-[10px] text-text-muted">{issue.id}</div>
<div className="ui-text line-clamp-1 text-sm font-medium text-text-body">{issue.title}</div>
</button>
))}
{columns[status].length > 6 ? (
<button
type="button"
onClick={() => onActivateStatus(status)}
className="rounded-lg border border-border-soft bg-surface/50 px-2 py-1 text-xs text-text-muted hover:bg-surface-muted/70"
>
+{columns[status].length - 6} more
</button>
) : null}
{columns[status].length === 0 ? (
<span className="rounded-lg border border-dashed border-border-soft/80 bg-surface/30 px-2 py-1 text-xs text-text-muted">
No beads
</span>
) : null}
</div>
)}
</div>
))}
</section>
);
}

View file

@ -1,127 +1,127 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { EpicChipStrip } from '../shared/epic-chip-strip';
import { StatPill } from '../shared/stat-pill';
interface KanbanControlsProps {
filters: KanbanFilterOptions;
stats: KanbanStats;
epics: BeadIssue[];
issues: BeadIssue[];
onFiltersChange: (filters: KanbanFilterOptions) => void;
onNextActionable: () => void;
nextActionableFeedback?: string | null;
}
export function KanbanControls({
filters,
stats,
epics,
issues,
onFiltersChange,
onNextActionable,
nextActionableFeedback = null,
}: KanbanControlsProps) {
const inputClass =
'ui-field rounded-xl px-3 py-2.5 text-sm outline-none transition';
// Build bead counts map for EpicChipStrip
// Count non-epic issues that have this epic as their parent
const beadCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const epic of epics) {
let count = 0;
for (const issue of issues) {
if (issue.issue_type === 'epic') continue;
const parentDep = issue.dependencies.find(d => d.type === 'parent');
const inferredParent = issue.id.includes('.') ? issue.id.split('.')[0] : null;
const parentEpicId = parentDep?.target ?? inferredParent;
if (parentEpicId === epic.id) {
count++;
}
}
counts.set(epic.id, count);
}
return counts;
}, [epics, issues]);
return (
<section className="grid gap-3">
{/* Epic selector - full width like /graph page */}
<motion.div layout>
<EpicChipStrip
epics={epics.filter((epic) => (filters.showClosed ? true : epic.status !== 'closed'))}
selectedEpicId={filters.epicId ?? null}
beadCounts={beadCounts}
onSelect={(epicId) => onFiltersChange({ ...filters, epicId: epicId || undefined })}
/>
</motion.div>
<motion.div layout className="grid grid-cols-1 gap-2.5 sm:flex sm:flex-wrap sm:items-center">
<input
type="search"
value={filters.query ?? ''}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
placeholder="Search by id/title/labels"
className={`${inputClass} w-full sm:min-w-[18rem] sm:flex-1`}
/>
<select
value={filters.type ?? ''}
onChange={(event) => onFiltersChange({ ...filters, type: event.target.value })}
className={`${inputClass} ui-select w-full sm:w-44`}
aria-label="Type filter"
>
<option className="ui-option" value="">All types</option>
<option className="ui-option" value="task">Task</option>
<option className="ui-option" value="bug">Bug</option>
<option className="ui-option" value="feature">Feature</option>
<option className="ui-option" value="epic">Epic</option>
<option className="ui-option" value="chore">Chore</option>
</select>
<select
value={filters.priority ?? ''}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value })}
className={`${inputClass} ui-select w-full sm:w-36`}
aria-label="Priority filter"
>
<option className="ui-option" value="">All priorities</option>
<option className="ui-option" value="0">P0</option>
<option className="ui-option" value="1">P1</option>
<option className="ui-option" value="2">P2</option>
<option className="ui-option" value="3">P3</option>
<option className="ui-option" value="4">P4</option>
</select>
<label className="ui-text inline-flex w-full items-center justify-center gap-2 rounded-xl border border-border-soft bg-gradient-to-b from-surface-muted/50 to-surface-muted/70 px-3 py-2 text-sm text-text-body sm:w-auto sm:justify-start shadow-[0_1px_3px_rgba(0,0,0,0.1)]">
<input
type="checkbox"
checked={filters.showClosed ?? false}
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
className="h-4 w-4 accent-amber-400"
/>
Show closed
</label>
<button
type="button"
onClick={onNextActionable}
className="ui-text w-full rounded-xl border border-border-soft bg-gradient-to-b from-surface-muted/60 to-surface-muted/80 px-3 py-2 text-sm font-semibold text-text-body transition hover:from-surface-muted/75 hover:to-surface-muted/90 shadow-[0_1px_3px_rgba(0,0,0,0.1)] sm:w-auto"
>
Next Actionable
</button>
</motion.div>
<motion.div layout className="flex flex-wrap gap-2">
<StatPill label="Total" value={stats.total} />
<StatPill label="Ready" value={stats.ready} />
<StatPill label="Active" value={stats.active} />
<StatPill label="Blocked" value={stats.blocked} />
<StatPill label="Done" value={stats.done} />
<StatPill label="P0" value={stats.p0} tone={stats.p0 > 0 ? 'critical' : 'default'} />
</motion.div>
{nextActionableFeedback ? <p className="ui-text text-xs text-text-muted">{nextActionableFeedback}</p> : null}
</section>
);
}
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { EpicChipStrip } from '../shared/epic-chip-strip';
import { StatPill } from '../shared/stat-pill';
interface KanbanControlsProps {
filters: KanbanFilterOptions;
stats: KanbanStats;
epics: BeadIssue[];
issues: BeadIssue[];
onFiltersChange: (filters: KanbanFilterOptions) => void;
onNextActionable: () => void;
nextActionableFeedback?: string | null;
}
export function KanbanControls({
filters,
stats,
epics,
issues,
onFiltersChange,
onNextActionable,
nextActionableFeedback = null,
}: KanbanControlsProps) {
const inputClass =
'ui-field rounded-xl px-3 py-2.5 text-sm outline-none transition';
// Build bead counts map for EpicChipStrip
// Count non-epic issues that have this epic as their parent
const beadCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const epic of epics) {
let count = 0;
for (const issue of issues) {
if (issue.issue_type === 'epic') continue;
const parentDep = issue.dependencies.find(d => d.type === 'parent');
const inferredParent = issue.id.includes('.') ? issue.id.split('.')[0] : null;
const parentEpicId = parentDep?.target ?? inferredParent;
if (parentEpicId === epic.id) {
count++;
}
}
counts.set(epic.id, count);
}
return counts;
}, [epics, issues]);
return (
<section className="grid gap-3">
{/* Epic selector - full width like /graph page */}
<motion.div layout>
<EpicChipStrip
epics={epics.filter((epic) => (filters.showClosed ? true : epic.status !== 'closed'))}
selectedEpicId={filters.epicId ?? null}
beadCounts={beadCounts}
onSelect={(epicId) => onFiltersChange({ ...filters, epicId: epicId || undefined })}
/>
</motion.div>
<motion.div layout className="grid grid-cols-1 gap-2.5 sm:flex sm:flex-wrap sm:items-center">
<input
type="search"
value={filters.query ?? ''}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
placeholder="Search by id/title/labels"
className={`${inputClass} w-full sm:min-w-[18rem] sm:flex-1`}
/>
<select
value={filters.type ?? ''}
onChange={(event) => onFiltersChange({ ...filters, type: event.target.value })}
className={`${inputClass} ui-select w-full sm:w-44`}
aria-label="Type filter"
>
<option className="ui-option" value="">All types</option>
<option className="ui-option" value="task">Task</option>
<option className="ui-option" value="bug">Bug</option>
<option className="ui-option" value="feature">Feature</option>
<option className="ui-option" value="epic">Epic</option>
<option className="ui-option" value="chore">Chore</option>
</select>
<select
value={filters.priority ?? ''}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value })}
className={`${inputClass} ui-select w-full sm:w-36`}
aria-label="Priority filter"
>
<option className="ui-option" value="">All priorities</option>
<option className="ui-option" value="0">P0</option>
<option className="ui-option" value="1">P1</option>
<option className="ui-option" value="2">P2</option>
<option className="ui-option" value="3">P3</option>
<option className="ui-option" value="4">P4</option>
</select>
<label className="ui-text inline-flex w-full items-center justify-center gap-2 rounded-xl border border-border-soft bg-gradient-to-b from-surface-muted/50 to-surface-muted/70 px-3 py-2 text-sm text-text-body sm:w-auto sm:justify-start shadow-[0_1px_3px_rgba(0,0,0,0.1)]">
<input
type="checkbox"
checked={filters.showClosed ?? false}
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
className="h-4 w-4 accent-amber-400"
/>
Show closed
</label>
<button
type="button"
onClick={onNextActionable}
className="ui-text w-full rounded-xl border border-border-soft bg-gradient-to-b from-surface-muted/60 to-surface-muted/80 px-3 py-2 text-sm font-semibold text-text-body transition hover:from-surface-muted/75 hover:to-surface-muted/90 shadow-[0_1px_3px_rgba(0,0,0,0.1)] sm:w-auto"
>
Next Actionable
</button>
</motion.div>
<motion.div layout className="flex flex-wrap gap-2">
<StatPill label="Total" value={stats.total} />
<StatPill label="Ready" value={stats.ready} />
<StatPill label="Active" value={stats.active} />
<StatPill label="Blocked" value={stats.blocked} />
<StatPill label="Done" value={stats.done} />
<StatPill label="P0" value={stats.p0} tone={stats.p0 > 0 ? 'critical' : 'default'} />
</motion.div>
{nextActionableFeedback ? <p className="ui-text text-xs text-text-muted">{nextActionableFeedback}</p> : null}
</section>
);
}

File diff suppressed because one or more lines are too long

View file

@ -1,110 +1,110 @@
'use client';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { useCallback, useMemo, useState } from 'react';
import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban';
import {
buildBlockedByTree,
buildKanbanColumns,
buildKanbanStats,
filterKanbanIssues,
findIssueLane,
laneToMutationStatus,
pickNextActionableIssue,
} from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback';
import { KanbanBoard } from './kanban-board';
import { KanbanControls } from './kanban-controls';
import { KanbanDetail } from './kanban-detail';
import { ProjectScopeControls } from '../shared/project-scope-controls';
import { WorkspaceHero } from '../shared/workspace-hero';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
interface KanbanPageProps {
issues: BeadIssue[];
projectRoot: string;
projectScopeKey: string;
projectScopeOptions: ProjectScopeOption[];
projectScopeMode: 'single' | 'aggregate';
}
type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
async function postMutation(operation: MutationOperation, body: Record<string, unknown>) {
const response = await fetch(`/api/beads/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message ?? `${operation} failed`);
}
}
export function KanbanPage({
issues: initialIssues,
projectRoot,
projectScopeKey,
projectScopeOptions,
projectScopeMode,
}: KanbanPageProps) {
const { issues: localIssues, refresh: refreshIssues, updateLocal: setLocalIssues } = useBeadsSubscription(initialIssues, projectRoot);
const [filters, setFilters] = useState<KanbanFilterOptions>({
query: '',
type: '',
priority: '',
showClosed: true,
});
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [activeStatus, setActiveStatus] = useState<KanbanStatus | null>('ready');
const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false);
const [nextActionableFeedback, setNextActionableFeedback] = useState<string | null>(null);
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
const [mutationError, setMutationError] = useState<string | null>(null);
const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]);
const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]);
const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]);
const parentEpicByIssueId = useMemo(() => {
const epicById = new Map(
localIssues.filter((issue) => issue.issue_type === 'epic').map((epic) => [epic.id, epic]),
);
const map = new Map<string, { id: string; title: string }>();
for (const issue of localIssues) {
if (issue.issue_type === 'epic') {
continue;
}
const parentDep = issue.dependencies.find((dependency) => dependency.type === 'parent');
const inferredParent = issue.id.includes('.') ? issue.id.split('.')[0] : null;
const parentEpicId = parentDep?.target ?? inferredParent;
if (!parentEpicId) {
continue;
}
const parentEpic = epicById.get(parentEpicId);
if (!parentEpic) {
continue;
}
map.set(issue.id, { id: parentEpic.id, title: parentEpic.title });
}
return map;
}, [localIssues]);
const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]);
const activeScope = useMemo(
() => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null,
[projectScopeKey, projectScopeOptions],
);
'use client';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { useCallback, useMemo, useState } from 'react';
import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban';
import {
buildBlockedByTree,
buildKanbanColumns,
buildKanbanStats,
filterKanbanIssues,
findIssueLane,
laneToMutationStatus,
pickNextActionableIssue,
} from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback';
import { KanbanBoard } from './kanban-board';
import { KanbanControls } from './kanban-controls';
import { KanbanDetail } from './kanban-detail';
import { ProjectScopeControls } from '../shared/project-scope-controls';
import { WorkspaceHero } from '../shared/workspace-hero';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
interface KanbanPageProps {
issues: BeadIssue[];
projectRoot: string;
projectScopeKey: string;
projectScopeOptions: ProjectScopeOption[];
projectScopeMode: 'single' | 'aggregate';
}
type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
async function postMutation(operation: MutationOperation, body: Record<string, unknown>) {
const response = await fetch(`/api/beads/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message ?? `${operation} failed`);
}
}
export function KanbanPage({
issues: initialIssues,
projectRoot,
projectScopeKey,
projectScopeOptions,
projectScopeMode,
}: KanbanPageProps) {
const { issues: localIssues, refresh: refreshIssues, updateLocal: setLocalIssues } = useBeadsSubscription(initialIssues, projectRoot);
const [filters, setFilters] = useState<KanbanFilterOptions>({
query: '',
type: '',
priority: '',
showClosed: true,
});
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [activeStatus, setActiveStatus] = useState<KanbanStatus | null>('ready');
const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false);
const [nextActionableFeedback, setNextActionableFeedback] = useState<string | null>(null);
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
const [mutationError, setMutationError] = useState<string | null>(null);
const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]);
const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]);
const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]);
const parentEpicByIssueId = useMemo(() => {
const epicById = new Map(
localIssues.filter((issue) => issue.issue_type === 'epic').map((epic) => [epic.id, epic]),
);
const map = new Map<string, { id: string; title: string }>();
for (const issue of localIssues) {
if (issue.issue_type === 'epic') {
continue;
}
const parentDep = issue.dependencies.find((dependency) => dependency.type === 'parent');
const inferredParent = issue.id.includes('.') ? issue.id.split('.')[0] : null;
const parentEpicId = parentDep?.target ?? inferredParent;
if (!parentEpicId) {
continue;
}
const parentEpic = epicById.get(parentEpicId);
if (!parentEpic) {
continue;
}
map.set(issue.id, { id: parentEpic.id, title: parentEpic.title });
}
return map;
}, [localIssues]);
const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]);
const activeScope = useMemo(
() => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null,
[projectScopeKey, projectScopeOptions],
);
const graphHref = useMemo(() => {
const params = new URLSearchParams();
params.set('view', 'graph');
@ -117,221 +117,221 @@ export function KanbanPage({
const query = params.toString();
return query ? `/?${query}` : '/?view=graph';
}, [projectScopeKey, projectScopeMode]);
const allowMutations = projectScopeMode === 'single';
const blockedTree = useMemo(
() => buildBlockedByTree(filteredIssues, selectedIssue?.id ?? null, { maxNodes: 8 }),
[filteredIssues, selectedIssue?.id],
);
const nextActionableIssue = useMemo(
() => pickNextActionableIssue(columns, filteredIssues),
[columns, filteredIssues],
);
const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized;
const focusIssueFromDetailLink = useCallback(
(issueId: string) => {
setSelectedIssueId(issueId);
setDesktopDetailMinimized(false);
const lane = findIssueLane(columns, issueId);
setActiveStatus(lane ?? 'ready');
},
[columns],
);
const selectIssueWithDetailBehavior = useCallback((issueId: string, lane: KanbanStatus = 'ready') => {
setSelectedIssueId(issueId);
setActiveStatus(lane);
setDesktopDetailMinimized(false);
setMobileDetailOpen(true);
}, []);
const handleNextActionable = useCallback(() => {
if (!nextActionableIssue) {
setNextActionableFeedback('No ready issue available for current filters.');
return;
}
setNextActionableFeedback(null);
selectIssueWithDetailBehavior(nextActionableIssue.id, 'ready');
}, [nextActionableIssue, selectIssueWithDetailBehavior]);
const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => {
if (!allowMutations) {
return;
}
const mutationStatus = laneToMutationStatus(targetStatus);
const steps = planStatusTransition(issue, mutationStatus);
if (steps.length === 0) {
return;
}
setMutationError(null);
const previous = localIssues;
setPendingIssueIds((value) => new Set(value).add(issue.id));
setLocalIssues((current) => applyOptimisticStatus(current, issue.id, mutationStatus));
try {
for (const step of steps) {
await postMutation(step.operation, {
projectRoot,
...step.payload,
});
}
await refreshIssues();
} catch (error) {
setLocalIssues(previous);
setMutationError(error instanceof Error ? error.message : 'Mutation failed');
} finally {
setPendingIssueIds((value) => {
const next = new Set(value);
next.delete(issue.id);
return next;
});
}
};
return (
<main className="mx-auto min-h-screen max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6">
<WorkspaceHero
eyebrow="BeadBoard Workspace"
title="Swimlanes"
description="Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance."
className="mb-4"
action={(
<Link
href={graphHref}
className="ui-text rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold text-text-body transition-all hover:bg-white/10 hover:border-white/20 sm:px-4 sm:text-xs"
>
Open Graph
</Link>
)}
scope={activeScope ? (
<p className="ui-text text-xs text-text-muted/90">
Scope:{' '}
<span className="system-data rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-text-body">
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
</span>
</p>
) : undefined}
controls={(
<>
<ProjectScopeControls
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
/>
{!allowMutations ? (
<p className="ui-text mt-2 text-xs text-amber-200/90">
Aggregate mode is read-only. Switch to single project mode to edit status/details.
</p>
) : null}
</>
)}
/>
<KanbanControls
filters={filters}
stats={stats}
epics={localIssues.filter((issue) => issue.issue_type === 'epic')}
issues={localIssues}
onFiltersChange={setFilters}
onNextActionable={handleNextActionable}
nextActionableFeedback={nextActionableFeedback}
/>
{mutationError ? (
<div className="ui-text mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
) : null}
<section
className={`mt-3 overflow-hidden rounded-2xl border border-white/5 bg-[linear-gradient(180deg,rgba(255,255,255,0.02),rgba(255,255,255,0.005))] shadow-[0_28px_62px_-18px_rgba(0,0,0,0.8),0_8px_24px_-10px_rgba(0,0,0,0.72)] backdrop-blur-xl ${
showDesktopDetail ? 'lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]' : ''
}`}
>
<motion.div layout className="p-2.5 sm:p-3">
<KanbanBoard
columns={columns}
parentEpicByIssueId={parentEpicByIssueId}
graphBaseHref={graphHref}
showClosed={Boolean(filters.showClosed)}
selectedIssueId={selectedIssue?.id ?? null}
pendingIssueIds={pendingIssueIds}
activeStatus={activeStatus}
onActivateStatus={setActiveStatus}
onMoveIssue={mutateStatus}
onSelect={(issue) => {
const lane = findIssueLane(columns, issue.id) ?? 'ready';
selectIssueWithDetailBehavior(issue.id, lane);
}}
/>
</motion.div>
{showDesktopDetail ? (
<div className="hidden border-t border-white/5 bg-[rgba(9,13,22,0.78)] p-3 lg:block lg:border-l lg:border-t-0">
<aside className="rounded-xl border border-white/6 bg-[linear-gradient(180deg,rgba(42,44,52,0.54),rgba(18,20,30,0.78))] p-3 shadow-[0_18px_42px_-20px_rgba(0,0,0,0.85),inset_0_1px_0_rgba(255,255,255,0.08)]">
<div className="mb-2 flex items-center justify-end gap-2 border-b border-border-soft pb-2">
<button
type="button"
onClick={() => setDesktopDetailMinimized(true)}
className="ui-text rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
>
Minimize
</button>
<button
type="button"
onClick={() => setSelectedIssueId(null)}
className="ui-text rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
>
Clear
</button>
</div>
<div className="max-h-[calc(100vh-16rem)] overflow-y-auto pr-1">
<KanbanDetail
issue={selectedIssue}
issues={filteredIssues}
framed={false}
blockedTree={blockedTree}
onSelectBlockedIssue={focusIssueFromDetailLink}
projectRoot={allowMutations ? projectRoot : undefined}
onIssueUpdated={() => refreshIssues()}
/>
</div>
</aside>
</div>
) : null}
</section>
{mobileDetailOpen && selectedIssue ? (
<div className="fixed inset-0 z-40 lg:hidden">
<button
type="button"
className="absolute inset-0 bg-black/82 backdrop-blur-md"
aria-label="Close details"
onClick={() => setMobileDetailOpen(false)}
/>
<motion.div
initial={{ y: 36, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 36, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-x-3 bottom-3 top-20 overflow-y-auto rounded-2xl border border-border-soft bg-surface/96 p-3 shadow-panel backdrop-blur-3xl"
>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => setMobileDetailOpen(false)}
className="ui-text rounded-lg border border-border-soft bg-surface-muted/70 px-3 py-1 text-xs font-semibold text-text-body"
>
Close
</button>
</div>
<KanbanDetail
issue={selectedIssue}
issues={filteredIssues}
framed={false}
blockedTree={blockedTree}
onSelectBlockedIssue={focusIssueFromDetailLink}
projectRoot={allowMutations ? projectRoot : undefined}
onIssueUpdated={() => refreshIssues()}
/>
</motion.div>
</div>
) : null}
</main>
);
}
const allowMutations = projectScopeMode === 'single';
const blockedTree = useMemo(
() => buildBlockedByTree(filteredIssues, selectedIssue?.id ?? null, { maxNodes: 8 }),
[filteredIssues, selectedIssue?.id],
);
const nextActionableIssue = useMemo(
() => pickNextActionableIssue(columns, filteredIssues),
[columns, filteredIssues],
);
const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized;
const focusIssueFromDetailLink = useCallback(
(issueId: string) => {
setSelectedIssueId(issueId);
setDesktopDetailMinimized(false);
const lane = findIssueLane(columns, issueId);
setActiveStatus(lane ?? 'ready');
},
[columns],
);
const selectIssueWithDetailBehavior = useCallback((issueId: string, lane: KanbanStatus = 'ready') => {
setSelectedIssueId(issueId);
setActiveStatus(lane);
setDesktopDetailMinimized(false);
setMobileDetailOpen(true);
}, []);
const handleNextActionable = useCallback(() => {
if (!nextActionableIssue) {
setNextActionableFeedback('No ready issue available for current filters.');
return;
}
setNextActionableFeedback(null);
selectIssueWithDetailBehavior(nextActionableIssue.id, 'ready');
}, [nextActionableIssue, selectIssueWithDetailBehavior]);
const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => {
if (!allowMutations) {
return;
}
const mutationStatus = laneToMutationStatus(targetStatus);
const steps = planStatusTransition(issue, mutationStatus);
if (steps.length === 0) {
return;
}
setMutationError(null);
const previous = localIssues;
setPendingIssueIds((value) => new Set(value).add(issue.id));
setLocalIssues((current) => applyOptimisticStatus(current, issue.id, mutationStatus));
try {
for (const step of steps) {
await postMutation(step.operation, {
projectRoot,
...step.payload,
});
}
await refreshIssues();
} catch (error) {
setLocalIssues(previous);
setMutationError(error instanceof Error ? error.message : 'Mutation failed');
} finally {
setPendingIssueIds((value) => {
const next = new Set(value);
next.delete(issue.id);
return next;
});
}
};
return (
<main className="mx-auto min-h-screen max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6">
<WorkspaceHero
eyebrow="BeadBoard Workspace"
title="Swimlanes"
description="Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance."
className="mb-4"
action={(
<Link
href={graphHref}
className="ui-text rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold text-text-body transition-all hover:bg-white/10 hover:border-white/20 sm:px-4 sm:text-xs"
>
Open Graph
</Link>
)}
scope={activeScope ? (
<p className="ui-text text-xs text-text-muted/90">
Scope:{' '}
<span className="system-data rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-text-body">
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
</span>
</p>
) : undefined}
controls={(
<>
<ProjectScopeControls
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
/>
{!allowMutations ? (
<p className="ui-text mt-2 text-xs text-amber-200/90">
Aggregate mode is read-only. Switch to single project mode to edit status/details.
</p>
) : null}
</>
)}
/>
<KanbanControls
filters={filters}
stats={stats}
epics={localIssues.filter((issue) => issue.issue_type === 'epic')}
issues={localIssues}
onFiltersChange={setFilters}
onNextActionable={handleNextActionable}
nextActionableFeedback={nextActionableFeedback}
/>
{mutationError ? (
<div className="ui-text mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
) : null}
<section
className={`mt-3 overflow-hidden rounded-2xl border border-white/5 bg-[linear-gradient(180deg,rgba(255,255,255,0.02),rgba(255,255,255,0.005))] shadow-[0_28px_62px_-18px_rgba(0,0,0,0.8),0_8px_24px_-10px_rgba(0,0,0,0.72)] backdrop-blur-xl ${
showDesktopDetail ? 'lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]' : ''
}`}
>
<motion.div layout className="p-2.5 sm:p-3">
<KanbanBoard
columns={columns}
parentEpicByIssueId={parentEpicByIssueId}
graphBaseHref={graphHref}
showClosed={Boolean(filters.showClosed)}
selectedIssueId={selectedIssue?.id ?? null}
pendingIssueIds={pendingIssueIds}
activeStatus={activeStatus}
onActivateStatus={setActiveStatus}
onMoveIssue={mutateStatus}
onSelect={(issue) => {
const lane = findIssueLane(columns, issue.id) ?? 'ready';
selectIssueWithDetailBehavior(issue.id, lane);
}}
/>
</motion.div>
{showDesktopDetail ? (
<div className="hidden border-t border-white/5 bg-[rgba(9,13,22,0.78)] p-3 lg:block lg:border-l lg:border-t-0">
<aside className="rounded-xl border border-white/6 bg-[linear-gradient(180deg,rgba(42,44,52,0.54),rgba(18,20,30,0.78))] p-3 shadow-[0_18px_42px_-20px_rgba(0,0,0,0.85),inset_0_1px_0_rgba(255,255,255,0.08)]">
<div className="mb-2 flex items-center justify-end gap-2 border-b border-border-soft pb-2">
<button
type="button"
onClick={() => setDesktopDetailMinimized(true)}
className="ui-text rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
>
Minimize
</button>
<button
type="button"
onClick={() => setSelectedIssueId(null)}
className="ui-text rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
>
Clear
</button>
</div>
<div className="max-h-[calc(100vh-16rem)] overflow-y-auto pr-1">
<KanbanDetail
issue={selectedIssue}
issues={filteredIssues}
framed={false}
blockedTree={blockedTree}
onSelectBlockedIssue={focusIssueFromDetailLink}
projectRoot={allowMutations ? projectRoot : undefined}
onIssueUpdated={() => refreshIssues()}
/>
</div>
</aside>
</div>
) : null}
</section>
{mobileDetailOpen && selectedIssue ? (
<div className="fixed inset-0 z-40 lg:hidden">
<button
type="button"
className="absolute inset-0 bg-black/82 backdrop-blur-md"
aria-label="Close details"
onClick={() => setMobileDetailOpen(false)}
/>
<motion.div
initial={{ y: 36, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 36, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-x-3 bottom-3 top-20 overflow-y-auto rounded-2xl border border-border-soft bg-surface/96 p-3 shadow-panel backdrop-blur-3xl"
>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => setMobileDetailOpen(false)}
className="ui-text rounded-lg border border-border-soft bg-surface-muted/70 px-3 py-1 text-xs font-semibold text-text-body"
>
Close
</button>
</div>
<KanbanDetail
issue={selectedIssue}
issues={filteredIssues}
framed={false}
blockedTree={blockedTree}
onSelectBlockedIssue={focusIssueFromDetailLink}
projectRoot={allowMutations ? projectRoot : undefined}
onIssueUpdated={() => refreshIssues()}
/>
</motion.div>
</div>
) : null}
</main>
);
}

View file

@ -1,106 +1,106 @@
'use client';
import { useEffect, useState } from 'react';
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
import { getAgentRoleColor } from './agent-station-logic';
function useTimeAgo(isoTimestamp: string) {
const [timeAgo, setTimeAgo] = useState('');
useEffect(() => {
const update = () => {
const seconds = Math.floor((new Date().getTime() - new Date(isoTimestamp).getTime()) / 1000);
if (seconds < 60) setTimeAgo(`${seconds}s`);
else if (seconds < 3600) setTimeAgo(`${Math.floor(seconds / 60)}m`);
else setTimeAgo(`${Math.floor(seconds / 3600)}h`);
};
update();
const interval = setInterval(update, 10000);
return () => clearInterval(interval);
}, [isoTimestamp]);
return timeAgo;
}
interface AgentStationProps {
agent: AgentRecord;
isSelected: boolean;
onSelect: (id: string | null) => void;
liveness: AgentLiveness;
missionCount?: number;
}
export function AgentStation({
agent,
isSelected,
onSelect,
liveness,
missionCount = 0
}: AgentStationProps) {
const timeAgo = useTimeAgo(agent.last_seen_at);
const roleColor = getAgentRoleColor(agent.role);
const statusStyles = {
active: {
dot: 'bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.8)]',
label: 'On Mission',
color: 'text-emerald-400/60'
},
stale: {
dot: 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]',
label: 'Lease Expiring',
color: 'text-amber-400/60'
},
evicted: {
dot: 'bg-rose-500/50 shadow-none',
label: 'Disconnected',
color: 'text-rose-400/40'
},
idle: {
dot: 'bg-zinc-700 shadow-none',
label: 'Idle',
color: 'text-zinc-500/30'
}
}[liveness];
return (
<button
onClick={() => onSelect(isSelected ? null : agent.agent_id)}
className={`flex-none group flex w-[10rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
isSelected
? 'border-sky-500/50 bg-sky-500/10 shadow-[0_0_10px_rgba(14,165,233,0.1)]'
: 'border-white/5 bg-white/[0.01] hover:bg-white/5'
} ${liveness === 'idle' ? 'opacity-40 grayscale-[0.5]' : ''}`}
>
<div className="relative flex-none">
<div className={`h-7 w-7 rounded-md bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border-2 ${roleColor} shadow-inner transition-transform duration-300 ${isSelected ? 'scale-90' : 'group-hover:scale-105'}`}>
<span className="ui-text text-[0.6rem] font-black text-white/80">
{agent.agent_id.slice(0, 2).toUpperCase()}
</span>
</div>
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${statusStyles.dot}`} />
</div>
<div className="flex flex-col items-start min-w-0 text-left">
<div className="flex items-center gap-1 w-full justify-between pr-1">
<span className={`ui-text text-[0.65rem] font-black truncate transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
{agent.agent_id}
</span>
<span className="system-data text-[0.5rem] font-bold text-text-muted/40">
{timeAgo}
</span>
</div>
<div className="flex items-center gap-1">
<span className={`system-data text-[0.5rem] font-bold uppercase tracking-tighter ${statusStyles.color}`}>
{statusStyles.label}
</span>
{missionCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-[0.9rem] px-1 rounded-full bg-sky-500/20 text-sky-400 text-[0.45rem] font-black">
{missionCount}
</span>
)}
</div>
</div>
</button>
);
}
'use client';
import { useEffect, useState } from 'react';
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
import { getAgentRoleColor } from './agent-station-logic';
function useTimeAgo(isoTimestamp: string) {
const [timeAgo, setTimeAgo] = useState('');
useEffect(() => {
const update = () => {
const seconds = Math.floor((new Date().getTime() - new Date(isoTimestamp).getTime()) / 1000);
if (seconds < 60) setTimeAgo(`${seconds}s`);
else if (seconds < 3600) setTimeAgo(`${Math.floor(seconds / 60)}m`);
else setTimeAgo(`${Math.floor(seconds / 3600)}h`);
};
update();
const interval = setInterval(update, 10000);
return () => clearInterval(interval);
}, [isoTimestamp]);
return timeAgo;
}
interface AgentStationProps {
agent: AgentRecord;
isSelected: boolean;
onSelect: (id: string | null) => void;
liveness: AgentLiveness;
missionCount?: number;
}
export function AgentStation({
agent,
isSelected,
onSelect,
liveness,
missionCount = 0
}: AgentStationProps) {
const timeAgo = useTimeAgo(agent.last_seen_at);
const roleColor = getAgentRoleColor(agent.role);
const statusStyles = {
active: {
dot: 'bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.8)]',
label: 'On Mission',
color: 'text-emerald-400/60'
},
stale: {
dot: 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]',
label: 'Lease Expiring',
color: 'text-amber-400/60'
},
evicted: {
dot: 'bg-rose-500/50 shadow-none',
label: 'Disconnected',
color: 'text-rose-400/40'
},
idle: {
dot: 'bg-zinc-700 shadow-none',
label: 'Idle',
color: 'text-zinc-500/30'
}
}[liveness];
return (
<button
onClick={() => onSelect(isSelected ? null : agent.agent_id)}
className={`flex-none group flex w-[10rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
isSelected
? 'border-sky-500/50 bg-sky-500/10 shadow-[0_0_10px_rgba(14,165,233,0.1)]'
: 'border-white/5 bg-white/[0.01] hover:bg-white/5'
} ${liveness === 'idle' ? 'opacity-40 grayscale-[0.5]' : ''}`}
>
<div className="relative flex-none">
<div className={`h-7 w-7 rounded-md bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border-2 ${roleColor} shadow-inner transition-transform duration-300 ${isSelected ? 'scale-90' : 'group-hover:scale-105'}`}>
<span className="ui-text text-[0.6rem] font-black text-white/80">
{agent.agent_id.slice(0, 2).toUpperCase()}
</span>
</div>
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${statusStyles.dot}`} />
</div>
<div className="flex flex-col items-start min-w-0 text-left">
<div className="flex items-center gap-1 w-full justify-between pr-1">
<span className={`ui-text text-[0.65rem] font-black truncate transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
{agent.agent_id}
</span>
<span className="system-data text-[0.5rem] font-bold text-text-muted/40">
{timeAgo}
</span>
</div>
<div className="flex items-center gap-1">
<span className={`system-data text-[0.5rem] font-bold uppercase tracking-tighter ${statusStyles.color}`}>
{statusStyles.label}
</span>
{missionCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-[0.9rem] px-1 rounded-full bg-sky-500/20 text-sky-400 text-[0.45rem] font-black">
{missionCount}
</span>
)}
</div>
</div>
</button>
);
}

View file

@ -1,112 +1,112 @@
'use client';
import { motion } from 'framer-motion';
import type { SessionTaskCard, Incursion } from '../../lib/agent-sessions';
import { statusBorder, statusDotColor, statusGradient, sessionStateGlow } from '../shared/status-utils';
interface SessionFeedCardProps {
card: SessionTaskCard;
onSelect: (id: string) => void;
isHighlighted?: boolean;
incursion?: Incursion;
highlightSource?: 'task' | 'agent';
}
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion, highlightSource }: SessionFeedCardProps) {
return (
<motion.article
layout
onClick={() => onSelect(card.id)}
className={`relative w-full cursor-pointer rounded-[1.25rem] border p-[1rem] text-left transition-all duration-200 ${
isHighlighted
? highlightSource === 'agent'
? 'border-emerald-500/50 bg-emerald-500/10 ring-1 ring-emerald-500/30 scale-[1.01] shadow-[0_0_16px_rgba(16,185,129,0.15)]'
: 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
: `${statusBorder(card.status)} ${statusGradient(card.status)} hover:bg-white/[0.04]`
} ${sessionStateGlow(card.sessionState)} ${incursion ? 'ring-1 ring-rose-500/30' : ''}`}
>
{/* Critical state badges */}
{card.sessionState === 'stuck' && (
<div className="absolute -top-2 right-4 z-10">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-red-500/80 text-white border border-red-400 shadow-lg animate-pulse">
<span aria-hidden="true"></span>
STUCK
</span>
</div>
)}
{card.sessionState === 'dead' && (
<div className="absolute -top-2 right-4 z-10">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-zinc-700/80 text-zinc-300 border border-zinc-600 shadow-lg">
<span aria-hidden="true"></span>
OFFLINE
</span>
</div>
)}
{incursion && (
<div className="absolute -top-2 right-4 z-10">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] border shadow-lg animate-pulse ${
incursion.severity === 'exact'
? 'bg-rose-500 text-white border-rose-400 shadow-rose-500/20'
: 'bg-amber-500 text-black border-amber-400 shadow-amber-500/20'
}`}>
Conflict
</span>
</div>
)}
<div className="flex gap-[0.75rem]">
{/* Compact Avatar */}
<div className="flex-none">
<div className="h-[2.5rem] w-[2.5rem] rounded-xl bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/5 shadow-inner">
<span className="ui-text text-[0.75rem] font-black text-zinc-400">
{card.owner?.slice(0, 2).toUpperCase() || '??'}
</span>
</div>
</div>
{/* Dense Headline Content */}
<div className="flex-1 min-w-0">
<header className="flex items-center justify-between gap-[0.5rem]">
<div className="flex flex-wrap items-center gap-[0.4rem]">
<span className="ui-text text-[0.8rem] font-black text-text-strong tracking-tight">{card.owner || 'Unassigned'}</span>
<span className="ui-text text-[0.7rem] text-text-muted/50">pulled</span>
<span className="system-data text-[0.7rem] font-black text-sky-400/80 uppercase tracking-widest">{card.id}</span>
</div>
<time className="system-data text-[0.65rem] text-text-muted/30 whitespace-nowrap">
{new Date(card.lastActivityAt || '').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
</header>
<div className="mt-[0.25rem]">
<h3 className="ui-text text-[0.85rem] font-bold leading-tight text-text-body/90 line-clamp-2 group-hover:text-text-strong">
{card.title}
</h3>
</div>
{card.communication.latestSnippet && (
<div className="mt-[0.75rem] relative rounded-xl bg-black/40 p-[0.75rem] border border-white/5 shadow-inner">
<p className="ui-text text-[0.75rem] italic leading-snug text-text-muted/80 line-clamp-2">
&quot;{card.communication.latestSnippet}&quot;
</p>
</div>
)}
<footer className="mt-[0.75rem] flex items-center justify-between">
<div className="flex items-center gap-[0.5rem]">
<span className={`h-[0.35rem] w-[0.35rem] rounded-full ${statusDotColor(card.status)} shadow-[0_0_6px_currentColor]`} />
<span className="system-data text-[0.6rem] font-black text-text-muted/40 uppercase tracking-widest">
{card.status}
</span>
</div>
<div className="flex gap-[0.4rem]">
<span className="ui-text text-[0.65rem] font-bold text-sky-400/60 uppercase tracking-tighter px-[0.4rem] py-[0.1rem] rounded-md bg-white/5 border border-white/5">
{card.sessionState}
</span>
</div>
</footer>
</div>
</div>
</motion.article>
);
}
'use client';
import { motion } from 'framer-motion';
import type { SessionTaskCard, Incursion } from '../../lib/agent-sessions';
import { statusBorder, statusDotColor, statusGradient, sessionStateGlow } from '../shared/status-utils';
interface SessionFeedCardProps {
card: SessionTaskCard;
onSelect: (id: string) => void;
isHighlighted?: boolean;
incursion?: Incursion;
highlightSource?: 'task' | 'agent';
}
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion, highlightSource }: SessionFeedCardProps) {
return (
<motion.article
layout
onClick={() => onSelect(card.id)}
className={`relative w-full cursor-pointer rounded-[1.25rem] border p-[1rem] text-left transition-all duration-200 ${
isHighlighted
? highlightSource === 'agent'
? 'border-emerald-500/50 bg-emerald-500/10 ring-1 ring-emerald-500/30 scale-[1.01] shadow-[0_0_16px_rgba(16,185,129,0.15)]'
: 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
: `${statusBorder(card.status)} ${statusGradient(card.status)} hover:bg-white/[0.04]`
} ${sessionStateGlow(card.sessionState)} ${incursion ? 'ring-1 ring-rose-500/30' : ''}`}
>
{/* Critical state badges */}
{card.sessionState === 'stuck' && (
<div className="absolute -top-2 right-4 z-10">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-red-500/80 text-white border border-red-400 shadow-lg animate-pulse">
<span aria-hidden="true"></span>
STUCK
</span>
</div>
)}
{card.sessionState === 'dead' && (
<div className="absolute -top-2 right-4 z-10">
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] bg-zinc-700/80 text-zinc-300 border border-zinc-600 shadow-lg">
<span aria-hidden="true"></span>
OFFLINE
</span>
</div>
)}
{incursion && (
<div className="absolute -top-2 right-4 z-10">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] border shadow-lg animate-pulse ${
incursion.severity === 'exact'
? 'bg-rose-500 text-white border-rose-400 shadow-rose-500/20'
: 'bg-amber-500 text-black border-amber-400 shadow-amber-500/20'
}`}>
Conflict
</span>
</div>
)}
<div className="flex gap-[0.75rem]">
{/* Compact Avatar */}
<div className="flex-none">
<div className="h-[2.5rem] w-[2.5rem] rounded-xl bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/5 shadow-inner">
<span className="ui-text text-[0.75rem] font-black text-zinc-400">
{card.owner?.slice(0, 2).toUpperCase() || '??'}
</span>
</div>
</div>
{/* Dense Headline Content */}
<div className="flex-1 min-w-0">
<header className="flex items-center justify-between gap-[0.5rem]">
<div className="flex flex-wrap items-center gap-[0.4rem]">
<span className="ui-text text-[0.8rem] font-black text-text-strong tracking-tight">{card.owner || 'Unassigned'}</span>
<span className="ui-text text-[0.7rem] text-text-muted/50">pulled</span>
<span className="system-data text-[0.7rem] font-black text-sky-400/80 uppercase tracking-widest">{card.id}</span>
</div>
<time className="system-data text-[0.65rem] text-text-muted/30 whitespace-nowrap">
{new Date(card.lastActivityAt || '').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
</header>
<div className="mt-[0.25rem]">
<h3 className="ui-text text-[0.85rem] font-bold leading-tight text-text-body/90 line-clamp-2 group-hover:text-text-strong">
{card.title}
</h3>
</div>
{card.communication.latestSnippet && (
<div className="mt-[0.75rem] relative rounded-xl bg-black/40 p-[0.75rem] border border-white/5 shadow-inner">
<p className="ui-text text-[0.75rem] italic leading-snug text-text-muted/80 line-clamp-2">
&quot;{card.communication.latestSnippet}&quot;
</p>
</div>
)}
<footer className="mt-[0.75rem] flex items-center justify-between">
<div className="flex items-center gap-[0.5rem]">
<span className={`h-[0.35rem] w-[0.35rem] rounded-full ${statusDotColor(card.status)} shadow-[0_0_6px_currentColor]`} />
<span className="system-data text-[0.6rem] font-black text-text-muted/40 uppercase tracking-widest">
{card.status}
</span>
</div>
<div className="flex gap-[0.4rem]">
<span className="ui-text text-[0.65rem] font-bold text-sky-400/60 uppercase tracking-tighter px-[0.4rem] py-[0.1rem] rounded-md bg-white/5 border border-white/5">
{card.sessionState}
</span>
</div>
</footer>
</div>
</div>
</motion.article>
);
}

View file

@ -1,109 +1,109 @@
'use client';
import { useMemo } from 'react';
import type { EpicBucket, Incursion } from '../../lib/agent-sessions';
import { SessionFeedCard } from './session-feed-card';
interface SessionTaskFeedProps {
feed: EpicBucket[];
incursions?: Incursion[];
selectedEpicId: string | null;
onSelectTask: (id: string) => void;
highlightTaskId?: string | null;
highlightingAgentId?: string | null;
}
export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
return (
<div className="flex flex-col gap-2">
{incursions.map((inc, i) => (
<div
key={i}
className={`flex items-center gap-3 px-4 py-2 rounded-xl border border-rose-500/20 bg-rose-500/5 backdrop-blur-md animate-in slide-in-from-top-4 duration-500`}
>
<div className="flex-none">
<span className={`flex h-2 w-2 rounded-full ${inc.severity === 'exact' ? 'bg-rose-500 animate-pulse' : 'bg-amber-500'}`} />
</div>
<p className="ui-text text-[0.7rem] font-bold text-rose-200/80">
<span className="uppercase tracking-widest mr-2 opacity-50">Conflict Detected:</span>
<span className="text-white mr-2">{inc.agents.join(' & ')}</span>
<span className="opacity-40 font-medium">overlapping in</span>
<span className="ml-2 font-mono text-rose-300/90">{inc.scope}</span>
</p>
</div>
))}
</div>
);
}
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId, highlightingAgentId }: SessionTaskFeedProps) {
const filteredFeed = useMemo(() => {
if (!selectedEpicId) return feed;
return feed.filter(b => b.epic.id === selectedEpicId);
}, [feed, selectedEpicId]);
if (filteredFeed.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2 rounded-3xl border border-dashed border-white/10 bg-white/[0.01]">
<p className="ui-text text-sm font-bold text-text-muted">No sessions found</p>
<p className="ui-text text-xs text-text-muted/50 text-center max-w-xs px-6">
Try selecting a different epic bucket or check if any tasks are active.
</p>
</div>
);
}
return (
<div className="space-y-16 pb-24">
{incursions.length > 0 && (
<div className="mb-8">
<IncursionTicker incursions={incursions} />
</div>
)}
{filteredFeed.map(bucket => (
<section key={bucket.epic.id} className="space-y-[1.5rem]">
<header className="flex items-center gap-[1rem] px-[0.5rem] group">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="ui-text text-[0.65rem] font-black uppercase tracking-[0.2em] text-sky-400/40">EPIC</span>
<h2 className="ui-text text-[0.9rem] font-black uppercase tracking-tight text-text-strong group-hover:text-sky-300 transition-colors">
{bucket.epic.title}
</h2>
</div>
<span className="system-data text-[0.65rem] font-bold text-text-muted/30 tracking-widest">{bucket.epic.id}</span>
</div>
<div className="h-px flex-1 bg-gradient-to-r from-white/[0.08] to-transparent" />
<div className="flex items-center gap-3">
<span className="system-data rounded-full border border-white/5 bg-white/[0.02] px-[0.6rem] py-[0.2rem] text-[0.7rem] font-black text-text-muted/60 shadow-inner">
{bucket.tasks.length} MISSION{bucket.tasks.length === 1 ? '' : 'S'}
</span>
</div>
</header>
<div className="grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-[1.5rem]">
{bucket.tasks.map(task => {
const taskIncursion = incursions.find(inc =>
task.owner && inc.agents.includes(task.owner)
);
const isAgentMission = highlightingAgentId ? task.owner === highlightingAgentId : false;
return (
<SessionFeedCard
key={task.id}
card={task}
onSelect={onSelectTask}
isHighlighted={highlightTaskId === task.id || isAgentMission}
incursion={taskIncursion}
highlightSource={isAgentMission ? 'agent' : undefined}
/>
);
})}
</div>
</section>
))}
</div>
);
}
'use client';
import { useMemo } from 'react';
import type { EpicBucket, Incursion } from '../../lib/agent-sessions';
import { SessionFeedCard } from './session-feed-card';
interface SessionTaskFeedProps {
feed: EpicBucket[];
incursions?: Incursion[];
selectedEpicId: string | null;
onSelectTask: (id: string) => void;
highlightTaskId?: string | null;
highlightingAgentId?: string | null;
}
export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
return (
<div className="flex flex-col gap-2">
{incursions.map((inc, i) => (
<div
key={i}
className={`flex items-center gap-3 px-4 py-2 rounded-xl border border-rose-500/20 bg-rose-500/5 backdrop-blur-md animate-in slide-in-from-top-4 duration-500`}
>
<div className="flex-none">
<span className={`flex h-2 w-2 rounded-full ${inc.severity === 'exact' ? 'bg-rose-500 animate-pulse' : 'bg-amber-500'}`} />
</div>
<p className="ui-text text-[0.7rem] font-bold text-rose-200/80">
<span className="uppercase tracking-widest mr-2 opacity-50">Conflict Detected:</span>
<span className="text-white mr-2">{inc.agents.join(' & ')}</span>
<span className="opacity-40 font-medium">overlapping in</span>
<span className="ml-2 font-mono text-rose-300/90">{inc.scope}</span>
</p>
</div>
))}
</div>
);
}
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId, highlightingAgentId }: SessionTaskFeedProps) {
const filteredFeed = useMemo(() => {
if (!selectedEpicId) return feed;
return feed.filter(b => b.epic.id === selectedEpicId);
}, [feed, selectedEpicId]);
if (filteredFeed.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2 rounded-3xl border border-dashed border-white/10 bg-white/[0.01]">
<p className="ui-text text-sm font-bold text-text-muted">No sessions found</p>
<p className="ui-text text-xs text-text-muted/50 text-center max-w-xs px-6">
Try selecting a different epic bucket or check if any tasks are active.
</p>
</div>
);
}
return (
<div className="space-y-16 pb-24">
{incursions.length > 0 && (
<div className="mb-8">
<IncursionTicker incursions={incursions} />
</div>
)}
{filteredFeed.map(bucket => (
<section key={bucket.epic.id} className="space-y-[1.5rem]">
<header className="flex items-center gap-[1rem] px-[0.5rem] group">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="ui-text text-[0.65rem] font-black uppercase tracking-[0.2em] text-sky-400/40">EPIC</span>
<h2 className="ui-text text-[0.9rem] font-black uppercase tracking-tight text-text-strong group-hover:text-sky-300 transition-colors">
{bucket.epic.title}
</h2>
</div>
<span className="system-data text-[0.65rem] font-bold text-text-muted/30 tracking-widest">{bucket.epic.id}</span>
</div>
<div className="h-px flex-1 bg-gradient-to-r from-white/[0.08] to-transparent" />
<div className="flex items-center gap-3">
<span className="system-data rounded-full border border-white/5 bg-white/[0.02] px-[0.6rem] py-[0.2rem] text-[0.7rem] font-black text-text-muted/60 shadow-inner">
{bucket.tasks.length} MISSION{bucket.tasks.length === 1 ? '' : 'S'}
</span>
</div>
</header>
<div className="grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-[1.5rem]">
{bucket.tasks.map(task => {
const taskIncursion = incursions.find(inc =>
task.owner && inc.agents.includes(task.owner)
);
const isAgentMission = highlightingAgentId ? task.owner === highlightingAgentId : false;
return (
<SessionFeedCard
key={task.id}
card={task}
onSelect={onSelectTask}
isHighlighted={highlightTaskId === task.id || isAgentMission}
incursion={taskIncursion}
highlightSource={isAgentMission ? 'agent' : undefined}
/>
);
})}
</div>
</section>
))}
</div>
);
}

View file

@ -1,155 +1,155 @@
'use client';
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
import { ProjectScopeControls } from '../shared/project-scope-controls';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { AgentStation } from './agent-station';
import { getSwarmHealth } from './sessions-header-logic';
export interface SwarmGroup {
swarmId: string;
swarmLabel: string;
members: AgentRecord[];
}
interface SessionsHeaderProps {
agents: AgentRecord[];
activeAgentId: string | null;
onSelectAgent: (id: string | null) => void;
projectScopeKey: string;
projectScopeMode: 'single' | 'aggregate';
projectScopeOptions: ProjectScopeOption[];
stats?: {
active: number;
needsInput: number;
completed: number;
};
livenessMap?: Record<string, string>;
swarmGroups?: SwarmGroup[];
unassignedAgents?: AgentRecord[];
missionCounts?: Record<string, number>;
}
export function SessionsHeader({
agents,
activeAgentId,
onSelectAgent,
projectScopeKey,
projectScopeMode,
projectScopeOptions,
stats,
livenessMap = {},
swarmGroups = [],
unassignedAgents = [],
missionCounts = {},
}: SessionsHeaderProps) {
return (
<header className="sticky top-0 z-50 flex flex-col border-b border-white/5 bg-[#0b0c10]/60 backdrop-blur-3xl shadow-2xl">
{/* Row 1: Agent Command Deck */}
<div className="flex h-14 items-center gap-4 px-6 border-b border-white/[0.03]">
<div className="flex-none pr-4 border-r border-white/5 mr-2">
<h1 className="ui-text text-[0.6rem] font-black uppercase tracking-[0.3em] text-text-strong/30">Command</h1>
<p className="ui-text text-[0.7rem] font-black text-text-strong">OPERATIVES</p>
</div>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
{swarmGroups.length > 0 || unassignedAgents.length > 0 ? (
<div className="flex items-center gap-4">
{swarmGroups.map((group) => {
const health = getSwarmHealth(group.members, livenessMap);
return (
<div key={group.swarmId} className="swarm-container flex items-center gap-2">
<div className="flex flex-col items-end">
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-sky-400/50 whitespace-nowrap">
{group.swarmLabel}
</span>
<span className={`ui-text text-[0.45rem] font-bold uppercase tracking-wider ${health.color} flex items-center gap-0.5`}>
<span className="text-xs"></span>
{health.status}
</span>
</div>
<div className="flex items-center gap-1">
{group.members.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))}
</div>
</div>
);
})}
{unassignedAgents.length > 0 && (
<div className="swarm-container flex items-center gap-2">
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-zinc-500/40 whitespace-nowrap">
No Swarm
</span>
<div className="flex items-center gap-1">
{unassignedAgents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))}
</div>
</div>
)}
</div>
) : (
agents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))
)}
</div>
</div>
{/* Row 2: Management & Meta */}
<div className="flex h-10 items-center justify-between px-6 bg-white/[0.01]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="ui-text text-[0.6rem] font-black uppercase tracking-[0.2em] text-sky-400/30 whitespace-nowrap">Load Pulse</span>
{stats && (
<div className="flex items-center gap-1.5">
<StatPill label="Active" value={stats.active} color="bg-emerald-500" />
<StatPill label="Blocked" value={stats.needsInput} color="bg-rose-500" />
</div>
)}
</div>
</div>
<div className="flex items-center gap-4 scale-75 origin-right opacity-70 hover:opacity-100 transition-opacity">
<ProjectScopeControls
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
/>
</div>
</div>
</header>
);
}
function StatPill({ label, value, color }: { label: string, value: number, color: string }) {
return (
<div className="flex items-center gap-1 rounded-full border border-white/5 bg-white/5 px-1.5 py-0.5">
<span className={`h-1 w-1 rounded-full ${color}`} />
<span className="system-data text-[8px] font-bold text-text-muted/60 uppercase tracking-tight">{label}</span>
<span className="system-data text-[8px] font-black text-text-strong">{value}</span>
</div>
);
}
'use client';
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
import { ProjectScopeControls } from '../shared/project-scope-controls';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { AgentStation } from './agent-station';
import { getSwarmHealth } from './sessions-header-logic';
export interface SwarmGroup {
swarmId: string;
swarmLabel: string;
members: AgentRecord[];
}
interface SessionsHeaderProps {
agents: AgentRecord[];
activeAgentId: string | null;
onSelectAgent: (id: string | null) => void;
projectScopeKey: string;
projectScopeMode: 'single' | 'aggregate';
projectScopeOptions: ProjectScopeOption[];
stats?: {
active: number;
needsInput: number;
completed: number;
};
livenessMap?: Record<string, string>;
swarmGroups?: SwarmGroup[];
unassignedAgents?: AgentRecord[];
missionCounts?: Record<string, number>;
}
export function SessionsHeader({
agents,
activeAgentId,
onSelectAgent,
projectScopeKey,
projectScopeMode,
projectScopeOptions,
stats,
livenessMap = {},
swarmGroups = [],
unassignedAgents = [],
missionCounts = {},
}: SessionsHeaderProps) {
return (
<header className="sticky top-0 z-50 flex flex-col border-b border-white/5 bg-[#0b0c10]/60 backdrop-blur-3xl shadow-2xl">
{/* Row 1: Agent Command Deck */}
<div className="flex h-14 items-center gap-4 px-6 border-b border-white/[0.03]">
<div className="flex-none pr-4 border-r border-white/5 mr-2">
<h1 className="ui-text text-[0.6rem] font-black uppercase tracking-[0.3em] text-text-strong/30">Command</h1>
<p className="ui-text text-[0.7rem] font-black text-text-strong">OPERATIVES</p>
</div>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
{swarmGroups.length > 0 || unassignedAgents.length > 0 ? (
<div className="flex items-center gap-4">
{swarmGroups.map((group) => {
const health = getSwarmHealth(group.members, livenessMap);
return (
<div key={group.swarmId} className="swarm-container flex items-center gap-2">
<div className="flex flex-col items-end">
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-sky-400/50 whitespace-nowrap">
{group.swarmLabel}
</span>
<span className={`ui-text text-[0.45rem] font-bold uppercase tracking-wider ${health.color} flex items-center gap-0.5`}>
<span className="text-xs"></span>
{health.status}
</span>
</div>
<div className="flex items-center gap-1">
{group.members.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))}
</div>
</div>
);
})}
{unassignedAgents.length > 0 && (
<div className="swarm-container flex items-center gap-2">
<span className="ui-text text-[0.55rem] font-black uppercase tracking-[0.15em] text-zinc-500/40 whitespace-nowrap">
No Swarm
</span>
<div className="flex items-center gap-1">
{unassignedAgents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))}
</div>
</div>
)}
</div>
) : (
agents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
missionCount={missionCounts[agent.agent_id]}
/>
))
)}
</div>
</div>
{/* Row 2: Management & Meta */}
<div className="flex h-10 items-center justify-between px-6 bg-white/[0.01]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="ui-text text-[0.6rem] font-black uppercase tracking-[0.2em] text-sky-400/30 whitespace-nowrap">Load Pulse</span>
{stats && (
<div className="flex items-center gap-1.5">
<StatPill label="Active" value={stats.active} color="bg-emerald-500" />
<StatPill label="Blocked" value={stats.needsInput} color="bg-rose-500" />
</div>
)}
</div>
</div>
<div className="flex items-center gap-4 scale-75 origin-right opacity-70 hover:opacity-100 transition-opacity">
<ProjectScopeControls
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
/>
</div>
</div>
</header>
);
}
function StatPill({ label, value, color }: { label: string, value: number, color: string }) {
return (
<div className="flex items-center gap-1 rounded-full border border-white/5 bg-white/5 px-1.5 py-0.5">
<span className={`h-1 w-1 rounded-full ${color}`} />
<span className="system-data text-[8px] font-bold text-text-muted/60 uppercase tracking-tight">{label}</span>
<span className="system-data text-[8px] font-black text-text-strong">{value}</span>
</div>
);
}

View file

@ -1,177 +1,177 @@
'use client';
import { useState, useMemo } from 'react';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
import { useSessionFeed } from '../../hooks/use-session-feed';
import { useTimelineStore } from '../timeline/timeline-store';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import type { AgentRecord } from '../../lib/agent-registry';
import { EpicChipStrip } from '../shared/epic-chip-strip';
import { SessionTaskFeed } from './session-task-feed';
import { ConversationDrawer } from './conversation-drawer';
import { SessionsHeader, type SwarmGroup } from './sessions-header';
import { getMissionsByAgent } from '../../lib/agent-sessions';
interface SessionsPageProps {
issues: BeadIssue[];
agents: AgentRecord[];
projectRoot: string;
projectScopeKey: string;
projectScopeOptions: ProjectScopeOption[];
projectScopeMode: 'single' | 'aggregate';
swarmGroups?: SwarmGroup[];
unassignedAgents?: AgentRecord[];
}
export function SessionsPage({
issues: initialIssues,
agents,
projectRoot,
projectScopeKey,
projectScopeOptions,
projectScopeMode,
swarmGroups = [],
unassignedAgents = [],
}: SessionsPageProps) {
const { feed, incursions, livenessMap, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
// Compute mission counts for agent header badges
const missionCounts = useMemo(() => {
const counts: Record<string, number> = {};
const missionsByAgent = getMissionsByAgent(feed);
for (const [agentId, missions] of Object.entries(missionsByAgent)) {
counts[agentId] = missions.length;
}
return counts;
}, [feed]);
const {
selectedAgentId,
selectedTaskId,
setSelectedAgentId,
setSelectedTaskId,
backToAgent
} = useTimelineStore();
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const { refresh: refreshIssues, issues: localIssues } = useBeadsSubscription(initialIssues, projectRoot, {
onUpdate: (kind) => {
if (kind === 'telemetry') return;
console.log(`[Sessions] ${kind} update detected. Scheduling silent refresh...`);
setTimeout(() => {
void refreshFeed({ silent: true });
setRefreshTrigger(prev => prev + 1);
}, 150);
}
});
const epics = initialIssues.filter(i => i.issue_type === 'epic');
const beadCounts = new Map(feed.map(b => [b.epic.id, b.tasks.length]));
const selectedBead = useMemo(() =>
localIssues.find(i => i.id === selectedTaskId) || null,
[localIssues, selectedTaskId]
);
return (
<div className="flex min-h-screen flex-col bg-[#070709]">
<SessionsHeader
agents={agents}
activeAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
stats={stats}
livenessMap={livenessMap}
swarmGroups={swarmGroups}
unassignedAgents={unassignedAgents}
missionCounts={missionCounts}
/>
<div className="flex flex-1 overflow-hidden">
{/* Main Activity Matrix */}
<main className="flex-1 overflow-y-auto custom-scrollbar">
<div className="mx-auto max-w-[90rem] px-[2rem] py-[2rem]">
<div className="mb-[2rem] overflow-x-auto pb-[0.5rem] no-scrollbar">
<EpicChipStrip
epics={epics}
selectedEpicId={selectedEpicId}
beadCounts={beadCounts}
onSelect={setSelectedEpicId}
/>
</div>
{loading ? (
<div className="flex h-[30rem] items-center justify-center text-text-muted">
<span className="animate-pulse tracking-[0.1em] uppercase text-[0.75rem] font-bold">
Synchronizing mission data...
</span>
</div>
) : (
<SessionTaskFeed
feed={feed}
incursions={incursions}
selectedEpicId={selectedEpicId}
onSelectTask={setSelectedTaskId}
highlightTaskId={selectedTaskId}
highlightingAgentId={selectedAgentId}
/>
)}
</div>
</main>
{/* Integrated Context Sidebar (Desktop Only) */}
<aside className={`hidden xl:block transition-all duration-500 ease-in-out border-l border-white/5 bg-[#0b0c10]/40 backdrop-blur-3xl overflow-hidden relative ${
(selectedTaskId || selectedAgentId) ? 'w-[28rem] opacity-100' : 'w-0 opacity-0 border-l-0'
}`}>
<ConversationDrawer
beadId={selectedTaskId}
bead={selectedBead}
agentId={selectedAgentId}
open={Boolean(selectedTaskId || selectedAgentId)}
onClose={() => {
setSelectedTaskId(null);
setSelectedAgentId(null);
}}
projectRoot={projectRoot}
onActivity={() => {
void refreshIssues();
void refreshFeed();
}}
showAgentContext={Boolean(selectedAgentId && selectedTaskId)}
onBackToAgent={backToAgent}
embedded={true}
refreshTrigger={refreshTrigger}
/>
</aside>
</div>
{/* Mobile/Tablet Drawer (fallback for small screens) */}
<div className="xl:hidden">
<ConversationDrawer
beadId={selectedTaskId}
bead={selectedBead}
agentId={selectedAgentId}
open={Boolean(selectedTaskId || selectedAgentId)}
onClose={() => {
setSelectedTaskId(null);
setSelectedAgentId(null);
}}
projectRoot={projectRoot}
onActivity={() => {
void refreshIssues();
void refreshFeed();
}}
showAgentContext={Boolean(selectedAgentId && selectedTaskId)}
onBackToAgent={backToAgent}
refreshTrigger={refreshTrigger}
/>
</div>
</div>
);
'use client';
import { useState, useMemo } from 'react';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
import { useSessionFeed } from '../../hooks/use-session-feed';
import { useTimelineStore } from '../timeline/timeline-store';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import type { AgentRecord } from '../../lib/agent-registry';
import { EpicChipStrip } from '../shared/epic-chip-strip';
import { SessionTaskFeed } from './session-task-feed';
import { ConversationDrawer } from './conversation-drawer';
import { SessionsHeader, type SwarmGroup } from './sessions-header';
import { getMissionsByAgent } from '../../lib/agent-sessions';
interface SessionsPageProps {
issues: BeadIssue[];
agents: AgentRecord[];
projectRoot: string;
projectScopeKey: string;
projectScopeOptions: ProjectScopeOption[];
projectScopeMode: 'single' | 'aggregate';
swarmGroups?: SwarmGroup[];
unassignedAgents?: AgentRecord[];
}
export function SessionsPage({
issues: initialIssues,
agents,
projectRoot,
projectScopeKey,
projectScopeOptions,
projectScopeMode,
swarmGroups = [],
unassignedAgents = [],
}: SessionsPageProps) {
const { feed, incursions, livenessMap, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
// Compute mission counts for agent header badges
const missionCounts = useMemo(() => {
const counts: Record<string, number> = {};
const missionsByAgent = getMissionsByAgent(feed);
for (const [agentId, missions] of Object.entries(missionsByAgent)) {
counts[agentId] = missions.length;
}
return counts;
}, [feed]);
const {
selectedAgentId,
selectedTaskId,
setSelectedAgentId,
setSelectedTaskId,
backToAgent
} = useTimelineStore();
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const { refresh: refreshIssues, issues: localIssues } = useBeadsSubscription(initialIssues, projectRoot, {
onUpdate: (kind) => {
if (kind === 'telemetry') return;
console.log(`[Sessions] ${kind} update detected. Scheduling silent refresh...`);
setTimeout(() => {
void refreshFeed({ silent: true });
setRefreshTrigger(prev => prev + 1);
}, 150);
}
});
const epics = initialIssues.filter(i => i.issue_type === 'epic');
const beadCounts = new Map(feed.map(b => [b.epic.id, b.tasks.length]));
const selectedBead = useMemo(() =>
localIssues.find(i => i.id === selectedTaskId) || null,
[localIssues, selectedTaskId]
);
return (
<div className="flex min-h-screen flex-col bg-[#070709]">
<SessionsHeader
agents={agents}
activeAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
stats={stats}
livenessMap={livenessMap}
swarmGroups={swarmGroups}
unassignedAgents={unassignedAgents}
missionCounts={missionCounts}
/>
<div className="flex flex-1 overflow-hidden">
{/* Main Activity Matrix */}
<main className="flex-1 overflow-y-auto custom-scrollbar">
<div className="mx-auto max-w-[90rem] px-[2rem] py-[2rem]">
<div className="mb-[2rem] overflow-x-auto pb-[0.5rem] no-scrollbar">
<EpicChipStrip
epics={epics}
selectedEpicId={selectedEpicId}
beadCounts={beadCounts}
onSelect={setSelectedEpicId}
/>
</div>
{loading ? (
<div className="flex h-[30rem] items-center justify-center text-text-muted">
<span className="animate-pulse tracking-[0.1em] uppercase text-[0.75rem] font-bold">
Synchronizing mission data...
</span>
</div>
) : (
<SessionTaskFeed
feed={feed}
incursions={incursions}
selectedEpicId={selectedEpicId}
onSelectTask={setSelectedTaskId}
highlightTaskId={selectedTaskId}
highlightingAgentId={selectedAgentId}
/>
)}
</div>
</main>
{/* Integrated Context Sidebar (Desktop Only) */}
<aside className={`hidden xl:block transition-all duration-500 ease-in-out border-l border-white/5 bg-[#0b0c10]/40 backdrop-blur-3xl overflow-hidden relative ${
(selectedTaskId || selectedAgentId) ? 'w-[28rem] opacity-100' : 'w-0 opacity-0 border-l-0'
}`}>
<ConversationDrawer
beadId={selectedTaskId}
bead={selectedBead}
agentId={selectedAgentId}
open={Boolean(selectedTaskId || selectedAgentId)}
onClose={() => {
setSelectedTaskId(null);
setSelectedAgentId(null);
}}
projectRoot={projectRoot}
onActivity={() => {
void refreshIssues();
void refreshFeed();
}}
showAgentContext={Boolean(selectedAgentId && selectedTaskId)}
onBackToAgent={backToAgent}
embedded={true}
refreshTrigger={refreshTrigger}
/>
</aside>
</div>
{/* Mobile/Tablet Drawer (fallback for small screens) */}
<div className="xl:hidden">
<ConversationDrawer
beadId={selectedTaskId}
bead={selectedBead}
agentId={selectedAgentId}
open={Boolean(selectedTaskId || selectedAgentId)}
onClose={() => {
setSelectedTaskId(null);
setSelectedAgentId(null);
}}
projectRoot={projectRoot}
onActivity={() => {
void refreshIssues();
void refreshFeed();
}}
showAgentContext={Boolean(selectedAgentId && selectedTaskId)}
onBackToAgent={backToAgent}
refreshTrigger={refreshTrigger}
/>
</div>
</div>
);
}

View file

@ -1,10 +1,10 @@
import type { ReactNode } from 'react';
interface ChipProps {
children: ReactNode;
tone?: 'default' | 'status' | 'priority';
}
import type { ReactNode } from 'react';
interface ChipProps {
children: ReactNode;
tone?: 'default' | 'status' | 'priority';
}
const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
default:
'border border-border-soft bg-gradient-to-b from-surface-muted/60 to-surface-muted/85 text-text-body shadow-[0_1px_2px_rgba(0,0,0,0.15)]',
@ -12,7 +12,7 @@ const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
priority:
'border border-amber-300/25 bg-gradient-to-b from-amber-500/15 to-amber-500/25 text-amber-50 shadow-[0_1px_2px_rgba(0,0,0,0.12)]',
};
export function Chip({ children, tone = 'default' }: ChipProps) {
return (
<span

View file

@ -1,66 +1,66 @@
'use client';
import { useState } from 'react';
import type { BeadIssue } from '../../lib/types';
/** Props for the EpicChipStrip component. */
interface EpicChipStripProps {
/** List of all epic issues to display as selectable chips. */
epics: BeadIssue[];
/** Currently selected epic ID, or null if none selected. */
selectedEpicId: string | null;
/** Map of epic ID to total bead (task) count. */
beadCounts: Map<string, number>;
/** Callback fired when the user clicks an epic chip. */
onSelect: (epicId: string) => void;
}
/**
* Returns the label and color for an epic's status.
*/
function statusStyle(status: BeadIssue['status']): { label: string; dot: string } {
switch (status) {
case 'open':
return { label: 'Open', dot: 'bg-sky-400' };
case 'in_progress':
return { label: 'In Progress', dot: 'bg-amber-400' };
case 'blocked':
return { label: 'Blocked', dot: 'bg-rose-500' };
case 'closed':
return { label: 'Done', dot: 'bg-emerald-400' };
case 'deferred':
return { label: 'Deferred', dot: 'bg-slate-400' };
default:
return { label: status, dot: 'bg-zinc-500' };
}
}
/**
* Renders an epic selector as a dropdown button that expands an inline selection panel.
* When collapsed: shows the selected epic's title as a button.
* When expanded: shows a horizontal strip of epic cards with ID, title, and status,
* pushing page content down naturally.
*/
export function EpicChipStrip({ epics, selectedEpicId, beadCounts, onSelect }: EpicChipStripProps) {
// Track whether the epic selector panel is expanded
const [expanded, setExpanded] = useState(false);
// Find the currently selected epic for the button label
const selectedEpic = epics.find((epic) => epic.id === selectedEpicId);
return (
<div className="relative">
{/* Collapsed state: button showing selected epic */}
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-left transition-all hover:bg-white/[0.07] hover:border-white/15 active:scale-[0.98] w-full"
>
{/* Status dot */}
{selectedEpic ? (
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${statusStyle(selectedEpic.status).dot}`} />
) : null}
'use client';
import { useState } from 'react';
import type { BeadIssue } from '../../lib/types';
/** Props for the EpicChipStrip component. */
interface EpicChipStripProps {
/** List of all epic issues to display as selectable chips. */
epics: BeadIssue[];
/** Currently selected epic ID, or null if none selected. */
selectedEpicId: string | null;
/** Map of epic ID to total bead (task) count. */
beadCounts: Map<string, number>;
/** Callback fired when the user clicks an epic chip. */
onSelect: (epicId: string) => void;
}
/**
* Returns the label and color for an epic's status.
*/
function statusStyle(status: BeadIssue['status']): { label: string; dot: string } {
switch (status) {
case 'open':
return { label: 'Open', dot: 'bg-sky-400' };
case 'in_progress':
return { label: 'In Progress', dot: 'bg-amber-400' };
case 'blocked':
return { label: 'Blocked', dot: 'bg-rose-500' };
case 'closed':
return { label: 'Done', dot: 'bg-emerald-400' };
case 'deferred':
return { label: 'Deferred', dot: 'bg-slate-400' };
default:
return { label: status, dot: 'bg-zinc-500' };
}
}
/**
* Renders an epic selector as a dropdown button that expands an inline selection panel.
* When collapsed: shows the selected epic's title as a button.
* When expanded: shows a horizontal strip of epic cards with ID, title, and status,
* pushing page content down naturally.
*/
export function EpicChipStrip({ epics, selectedEpicId, beadCounts, onSelect }: EpicChipStripProps) {
// Track whether the epic selector panel is expanded
const [expanded, setExpanded] = useState(false);
// Find the currently selected epic for the button label
const selectedEpic = epics.find((epic) => epic.id === selectedEpicId);
return (
<div className="relative">
{/* Collapsed state: button showing selected epic */}
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-left transition-all hover:bg-white/[0.07] hover:border-white/15 active:scale-[0.98] w-full"
>
{/* Status dot */}
{selectedEpic ? (
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${statusStyle(selectedEpic.status).dot}`} />
) : null}
{/* Selected epic label */}
<div className="min-w-0 flex-1">
<span className="block text-[10px] font-bold uppercase tracking-wider text-text-muted/50">
@ -70,13 +70,13 @@ export function EpicChipStrip({ epics, selectedEpicId, beadCounts, onSelect }: E
{selectedEpic ? selectedEpic.title : 'All Epics'}
</span>
</div>
{/* Expand/collapse chevron */}
<span className="text-text-muted/50 text-sm shrink-0">
{expanded ? '\u25b2' : '\u25bc'}
</span>
</button>
{/* Expand/collapse chevron */}
<span className="text-text-muted/50 text-sm shrink-0">
{expanded ? '\u25b2' : '\u25bc'}
</span>
</button>
{/* Expanded state: horizontal card strip */}
{expanded ? (
<div className="mt-2 rounded-2xl border border-white/8 bg-[#0c0e14]/95 p-3 shadow-[0_16px_48px_rgba(0,0,0,0.5)] backdrop-blur-lg animate-fade-in">
@ -112,57 +112,57 @@ export function EpicChipStrip({ epics, selectedEpicId, beadCounts, onSelect }: E
</div>
</button>
{epics.map((epic) => {
// Determine if this card is the currently selected epic
const isSelected = epic.id === selectedEpicId;
// Closed epics get a muted visual treatment
const isClosed = epic.status === 'closed';
const style = statusStyle(epic.status);
const count = beadCounts.get(epic.id) ?? 0;
return (
<button
key={epic.id}
type="button"
onClick={() => {
onSelect(epic.id);
setExpanded(false);
}}
className={`flex flex-col gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${isSelected
? 'border-sky-400/40 bg-sky-400/10 ring-1 ring-sky-400/15'
: isClosed
? 'border-white/5 bg-white/[0.02] opacity-50 hover:opacity-80'
: 'border-white/8 bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/15'
}`}
>
{/* Top row: ID + Status + Priority */}
<div className="flex items-center justify-between gap-2 w-full">
<span className="font-mono text-[9px] uppercase tracking-wider text-text-muted/60">{epic.id}</span>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1 rounded-md bg-white/5 px-1.5 py-0.5">
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
<span className="text-[9px] font-bold uppercase tracking-wider text-text-muted/70">{style.label}</span>
</div>
<span className="text-[10px] font-bold text-amber-400/80 bg-amber-400/10 px-1.5 py-0.5 rounded">P{epic.priority}</span>
</div>
</div>
{/* Epic title */}
<p className={`text-[12px] font-semibold leading-tight text-text-strong line-clamp-2 ${isClosed ? 'line-through' : ''}`}>
{epic.title}
</p>
{/* Metadata Row: Bead Count */}
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-text-muted bg-white/5 px-2 py-0.5 rounded-full border border-white/5">
{count} {count === 1 ? 'bead' : 'beads'}
</span>
</div>
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}
// Determine if this card is the currently selected epic
const isSelected = epic.id === selectedEpicId;
// Closed epics get a muted visual treatment
const isClosed = epic.status === 'closed';
const style = statusStyle(epic.status);
const count = beadCounts.get(epic.id) ?? 0;
return (
<button
key={epic.id}
type="button"
onClick={() => {
onSelect(epic.id);
setExpanded(false);
}}
className={`flex flex-col gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${isSelected
? 'border-sky-400/40 bg-sky-400/10 ring-1 ring-sky-400/15'
: isClosed
? 'border-white/5 bg-white/[0.02] opacity-50 hover:opacity-80'
: 'border-white/8 bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/15'
}`}
>
{/* Top row: ID + Status + Priority */}
<div className="flex items-center justify-between gap-2 w-full">
<span className="font-mono text-[9px] uppercase tracking-wider text-text-muted/60">{epic.id}</span>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1 rounded-md bg-white/5 px-1.5 py-0.5">
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
<span className="text-[9px] font-bold uppercase tracking-wider text-text-muted/70">{style.label}</span>
</div>
<span className="text-[10px] font-bold text-amber-400/80 bg-amber-400/10 px-1.5 py-0.5 rounded">P{epic.priority}</span>
</div>
</div>
{/* Epic title */}
<p className={`text-[12px] font-semibold leading-tight text-text-strong line-clamp-2 ${isClosed ? 'line-through' : ''}`}>
{epic.title}
</p>
{/* Metadata Row: Bead Count */}
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-text-muted bg-white/5 px-2 py-0.5 rounded-full border border-white/5">
{count} {count === 1 ? 'bead' : 'beads'}
</span>
</div>
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}

View file

@ -1,466 +1,491 @@
'use client';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import { cn } from '../../lib/utils';
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents';
export interface LeftPanelFilters {
query: string;
status: LeftPanelStatusFilter;
priority: LeftPanelPriorityFilter;
preset: LeftPanelPresetFilter;
hideClosed: boolean;
}
export interface LeftPanelProps {
issues: BeadIssue[];
selectedEpicId?: string | null;
onEpicSelect?: (epicId: string | null) => void;
onEpicEdit?: (epicId: string) => void;
filters: LeftPanelFilters;
onFiltersChange: (filters: LeftPanelFilters) => void;
onAssignMode?: (epicId: string) => void;
}
'use client';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Rocket, Star } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import { cn } from '../../lib/utils';
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents';
export interface LeftPanelFilters {
query: string;
status: LeftPanelStatusFilter;
priority: LeftPanelPriorityFilter;
preset: LeftPanelPresetFilter;
hideClosed: boolean;
}
export interface LeftPanelProps {
issues: BeadIssue[];
selectedEpicId?: string | null;
onEpicSelect?: (epicId: string | null) => void;
onEpicEdit?: (epicId: string) => void;
filters: LeftPanelFilters;
onFiltersChange: (filters: LeftPanelFilters) => void;
onAssignMode?: (epicId: string) => void;
}
interface EpicEntry {
epic: BeadIssue;
children: BeadIssue[];
blockedCount: number;
activeCount: number;
readyCount: number;
deferredCount: number;
doneCount: number;
agentBlockedCount: number;
epic: BeadIssue;
children: BeadIssue[];
blockedCount: number;
activeCount: number;
readyCount: number;
deferredCount: number;
doneCount: number;
agentBlockedCount: number;
latestTimestamp: string;
}
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
if (task.status === 'open') return 'ready';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'blocked') return 'blocked';
if (task.status === 'closed' || task.status === 'tombstone') return 'done';
return 'deferred';
}
function mapPriority(task: BeadIssue): LeftPanelPriorityFilter {
if (task.priority <= 0) return 'P0';
if (task.priority === 1) return 'P1';
if (task.priority === 2) return 'P2';
if (task.priority === 3) return 'P3';
return 'P4';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMinutes = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function buildEntries(issues: BeadIssue[]): EpicEntry[] {
const epics = issues.filter((issue) => issue.issue_type === 'epic' && !issue.labels?.includes('memory-anchor'));
const tasks = issues.filter((issue) => issue.issue_type !== 'epic');
const taskById = new Map(tasks.map((task) => [task.id, task] as const));
const incomingBlockers = new Map<string, string[]>();
for (const task of tasks) {
incomingBlockers.set(task.id, []);
}
for (const task of tasks) {
for (const dependency of task.dependencies) {
if (dependency.type !== 'blocks') continue;
if (!taskById.has(dependency.target)) continue;
const current = incomingBlockers.get(dependency.target) ?? [];
current.push(task.id);
incomingBlockers.set(dependency.target, current);
}
}
const isEffectivelyBlocked = (task: BeadIssue): boolean => {
if (task.status === 'blocked') return true;
if (task.status === 'closed' || task.status === 'tombstone') return false;
const blockers = incomingBlockers.get(task.id) ?? [];
return blockers.some((blockerId) => {
const blocker = taskById.get(blockerId);
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
});
};
return epics
.map((epic) => {
const children = tasks
.filter((task) => task.dependencies.some((dep) => dep.type === 'parent' && dep.target === epic.id))
.sort((a, b) => a.id.localeCompare(b.id));
const blockedCount = children.filter((task) => isEffectivelyBlocked(task)).length;
const activeCount = children.filter((task) => task.status === 'in_progress').length;
const readyCount = children.filter((task) => task.status === 'open' && !isEffectivelyBlocked(task)).length;
const deferredCount = children.filter((task) => task.status === 'deferred').length;
const doneCount = children.filter((task) => task.status === 'closed' || task.status === 'tombstone').length;
const agentBlockedCount = children.filter(
(task) =>
isEffectivelyBlocked(task) &&
(Boolean(task.assignee) ||
task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:') || label.startsWith('gt:agent:'))),
).length;
const latestTimestamp = [epic.updated_at, ...children.map((child) => child.updated_at)]
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? epic.updated_at;
return {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
};
})
.sort((a, b) => a.epic.id.localeCompare(b.epic.id));
}
function statusDot(status: BeadIssue['status']): string {
if (status === 'blocked') return 'bg-[var(--accent-danger)]';
if (status === 'in_progress') return 'bg-[var(--accent-warning)]';
if (status === 'closed') return 'bg-[var(--text-tertiary)]';
return 'bg-[var(--accent-success)]';
}
function rowTone(entry: EpicEntry): string {
if (entry.blockedCount > 0) {
return 'rgba(255, 76, 114, 0.08)';
}
if (entry.activeCount > 0) {
return 'rgba(255, 178, 74, 0.08)';
}
if (entry.readyCount > 0) {
return 'rgba(53, 217, 143, 0.08)';
}
return 'var(--surface-tertiary)';
}
function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
if (filters.hideClosed && (task.status === 'closed' || task.status === 'tombstone')) return false;
const normalizedQuery = filters.query.trim().toLowerCase();
if (normalizedQuery.length > 0) {
const searchable = `${task.id} ${task.title} ${task.labels.join(' ')}`.toLowerCase();
if (!searchable.includes(normalizedQuery)) return false;
}
if (filters.status !== 'all' && mapStatus(task) !== filters.status) return false;
if (filters.priority !== 'all' && mapPriority(task) !== filters.priority) return false;
if (filters.preset === 'active' && task.status !== 'in_progress') return false;
if (
filters.preset === 'blocked_agents' &&
!(
task.status === 'blocked' &&
(Boolean(task.assignee) || task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:')))
)
) {
return false;
}
return true;
}
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, filters, onFiltersChange, onAssignMode }: LeftPanelProps) {
const { view, setView } = useUrlState();
const entries = useMemo(() => buildEntries(issues), [issues]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const hasActiveFilters =
export function shouldHideEpicEntry(params: {
epicStatus: BeadIssue['status'];
matchedChildrenCount: number;
totalChildrenCount: number;
isSelected: boolean;
filters: LeftPanelFilters;
}): boolean {
const { epicStatus, matchedChildrenCount, totalChildrenCount, isSelected, filters } = params;
const hasTaskFilters =
filters.query.trim().length > 0 ||
filters.status !== 'all' ||
filters.priority !== 'all' ||
filters.preset !== 'all' ||
filters.hideClosed;
filters.preset !== 'all';
const epicClosed = epicStatus === 'closed' || epicStatus === 'tombstone';
const noVisibleChildren = matchedChildrenCount === 0 && totalChildrenCount > 0;
const hiddenByTaskFilters = hasTaskFilters && noVisibleChildren;
const hiddenByHideClosed = filters.hideClosed && noVisibleChildren;
const hiddenByEpicClosed = filters.hideClosed && epicClosed;
if (hiddenByEpicClosed) {
return true;
}
return !isSelected && (hiddenByTaskFilters || hiddenByHideClosed);
}
function mapStatus(task: BeadIssue): LeftPanelStatusFilter {
if (task.status === 'open') return 'ready';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'blocked') return 'blocked';
if (task.status === 'closed' || task.status === 'tombstone') return 'done';
return 'deferred';
}
function mapPriority(task: BeadIssue): LeftPanelPriorityFilter {
if (task.priority <= 0) return 'P0';
if (task.priority === 1) return 'P1';
if (task.priority === 2) return 'P2';
if (task.priority === 3) return 'P3';
return 'P4';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMinutes = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function buildEntries(issues: BeadIssue[]): EpicEntry[] {
const epics = issues.filter((issue) => issue.issue_type === 'epic' && !issue.labels?.includes('memory-anchor'));
const tasks = issues.filter((issue) => issue.issue_type !== 'epic');
const taskById = new Map(tasks.map((task) => [task.id, task] as const));
const incomingBlockers = new Map<string, string[]>();
for (const task of tasks) {
incomingBlockers.set(task.id, []);
}
for (const task of tasks) {
for (const dependency of task.dependencies) {
if (dependency.type !== 'blocks') continue;
if (!taskById.has(dependency.target)) continue;
const current = incomingBlockers.get(dependency.target) ?? [];
current.push(task.id);
incomingBlockers.set(dependency.target, current);
}
}
const isEffectivelyBlocked = (task: BeadIssue): boolean => {
if (task.status === 'blocked') return true;
if (task.status === 'closed' || task.status === 'tombstone') return false;
const blockers = incomingBlockers.get(task.id) ?? [];
return blockers.some((blockerId) => {
const blocker = taskById.get(blockerId);
return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false;
});
};
return epics
.map((epic) => {
const children = tasks
.filter((task) => task.dependencies.some((dep) => dep.type === 'parent' && dep.target === epic.id))
.sort((a, b) => a.id.localeCompare(b.id));
const blockedCount = children.filter((task) => isEffectivelyBlocked(task)).length;
const activeCount = children.filter((task) => task.status === 'in_progress').length;
const readyCount = children.filter((task) => task.status === 'open' && !isEffectivelyBlocked(task)).length;
const deferredCount = children.filter((task) => task.status === 'deferred').length;
const doneCount = children.filter((task) => task.status === 'closed' || task.status === 'tombstone').length;
const agentBlockedCount = children.filter(
(task) =>
isEffectivelyBlocked(task) &&
(Boolean(task.assignee) ||
task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:') || label.startsWith('gt:agent:'))),
).length;
const latestTimestamp = [epic.updated_at, ...children.map((child) => child.updated_at)]
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? epic.updated_at;
return {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
};
})
.sort((a, b) => a.epic.id.localeCompare(b.epic.id));
}
function statusDot(status: BeadIssue['status']): string {
if (status === 'blocked') return 'bg-[var(--accent-danger)]';
if (status === 'in_progress') return 'bg-[var(--accent-warning)]';
if (status === 'closed') return 'bg-[var(--text-tertiary)]';
return 'bg-[var(--accent-success)]';
}
function rowTone(entry: EpicEntry): string {
if (entry.blockedCount > 0) {
return 'rgba(255, 76, 114, 0.08)';
}
if (entry.activeCount > 0) {
return 'rgba(255, 178, 74, 0.08)';
}
if (entry.readyCount > 0) {
return 'rgba(53, 217, 143, 0.08)';
}
return 'var(--surface-tertiary)';
}
function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean {
if (filters.hideClosed && (task.status === 'closed' || task.status === 'tombstone')) return false;
const normalizedQuery = filters.query.trim().toLowerCase();
if (normalizedQuery.length > 0) {
const searchable = `${task.id} ${task.title} ${task.labels.join(' ')}`.toLowerCase();
if (!searchable.includes(normalizedQuery)) return false;
}
if (filters.status !== 'all' && mapStatus(task) !== filters.status) return false;
if (filters.priority !== 'all' && mapPriority(task) !== filters.priority) return false;
if (filters.preset === 'active' && task.status !== 'in_progress') return false;
if (
filters.preset === 'blocked_agents' &&
!(
task.status === 'blocked' &&
(Boolean(task.assignee) || task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:')))
)
) {
return false;
}
return true;
}
export function LeftPanel({ issues, selectedEpicId, onEpicSelect, onEpicEdit, filters, onFiltersChange, onAssignMode }: LeftPanelProps) {
const { view, setView } = useUrlState();
const entries = useMemo(() => buildEntries(issues), [issues]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const views: Array<{ id: ViewType; label: string }> = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
];
return (
<aside className="flex h-full min-h-0 overflow-hidden flex-col bg-[var(--surface-primary)] border-r border-[var(--border-strong)]" data-testid="left-panel">
<div className="px-4 py-3">
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-strong)]">
{views.map((item) => {
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
active
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]',
)}
>
{item.label}
</button>
);
})}
</div>
<div className="space-y-2 rounded-xl bg-[var(--surface-quaternary)] p-2.5 border border-[var(--border-subtle)]">
<div className="grid grid-cols-1 gap-2">
<input
value={filters.query}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
className="ui-field rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
placeholder="Filter Tasks…"
aria-label="Filter tasks"
autoComplete="off"
/>
<div className="grid grid-cols-2 gap-2">
<select
value={filters.status}
onChange={(event) => onFiltersChange({ ...filters, status: event.target.value as LeftPanelStatusFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Status filter"
>
<option className="ui-option" value="all">All Status</option>
<option className="ui-option" value="ready">Ready</option>
<option className="ui-option" value="in_progress">In Progress</option>
<option className="ui-option" value="blocked">Blocked</option>
<option className="ui-option" value="deferred">Deferred</option>
<option className="ui-option" value="done">Done</option>
</select>
<select
value={filters.priority}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value as LeftPanelPriorityFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Priority filter"
>
<option className="ui-option" value="all">All Priority</option>
<option className="ui-option" value="P0">P0</option>
<option className="ui-option" value="P1">P1</option>
<option className="ui-option" value="P2">P2</option>
<option className="ui-option" value="P3">P3</option>
<option className="ui-option" value="P4">P4</option>
</select>
</div>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'active' ? 'all' : 'active' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.preset === 'active'
? 'bg-[var(--accent-warning)]/15 border-[var(--accent-warning)]/40 text-[var(--accent-warning)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.preset === 'active'}
>
Active
</button>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'blocked_agents' ? 'all' : 'blocked_agents' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.preset === 'blocked_agents'
? 'bg-[var(--accent-danger)]/15 border-[var(--accent-danger)]/40 text-[var(--accent-danger)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.preset === 'blocked_agents'}
>
Agent Blocked
</button>
</div>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, hideClosed: !filters.hideClosed })}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.hideClosed
? 'bg-[var(--accent-success)]/15 border-[var(--accent-success)]/40 text-[var(--accent-success)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.hideClosed}
>
Hide Closed
</button>
</div>
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--text-tertiary)]">Navigation / Epics</p>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{entries.map((entry) => {
const {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
} = entry;
const matchedChildren = children.filter((task) => isTaskMatch(task, filters));
const total = children.length;
const donePercent = total > 0 ? Math.round((doneCount / total) * 100) : 0;
const readyPercent = total > 0 ? Math.round((readyCount / total) * 100) : 0;
const activePercent = total > 0 ? Math.round((activeCount / total) * 100) : 0;
const blockedPercent = total > 0 ? Math.round((blockedCount / total) * 100) : 0;
const isExpanded = expanded[epic.id] ?? false;
const isSelected = selectedEpicId === epic.id;
const laneColor = blockedCount > 0 ? 'var(--accent-danger)' : activeCount > 0 ? 'var(--accent-warning)' : 'var(--accent-success)';
const rowBackground = rowTone(entry);
if (matchedChildren.length === 0 && hasActiveFilters && !isSelected) {
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
];
return (
<aside className="flex h-full min-h-0 overflow-hidden flex-col bg-[var(--surface-primary)] border-r border-[var(--border-strong)]" data-testid="left-panel">
<div className="px-4 py-3">
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[var(--surface-tertiary)] p-1 border border-[var(--border-strong)]">
{views.map((item) => {
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
active
? 'bg-[var(--accent-info)]/20 text-[var(--accent-info)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]',
)}
>
{item.label}
</button>
);
})}
</div>
<div className="space-y-2 rounded-xl bg-[var(--surface-quaternary)] p-2.5 border border-[var(--border-subtle)]">
<div className="grid grid-cols-1 gap-2">
<input
value={filters.query}
onChange={(event) => onFiltersChange({ ...filters, query: event.target.value })}
className="ui-field rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
placeholder="Filter Tasks…"
aria-label="Filter tasks"
autoComplete="off"
/>
<div className="grid grid-cols-2 gap-2">
<select
value={filters.status}
onChange={(event) => onFiltersChange({ ...filters, status: event.target.value as LeftPanelStatusFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Status filter"
>
<option className="ui-option" value="all">All Status</option>
<option className="ui-option" value="ready">Ready</option>
<option className="ui-option" value="in_progress">In Progress</option>
<option className="ui-option" value="blocked">Blocked</option>
<option className="ui-option" value="deferred">Deferred</option>
<option className="ui-option" value="done">Done</option>
</select>
<select
value={filters.priority}
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value as LeftPanelPriorityFilter })}
className="ui-field ui-select rounded-lg border-transparent px-2.5 py-2 text-xs shadow-[0_10px_20px_-16px_rgba(0,0,0,0.9)]"
aria-label="Priority filter"
>
<option className="ui-option" value="all">All Priority</option>
<option className="ui-option" value="P0">P0</option>
<option className="ui-option" value="P1">P1</option>
<option className="ui-option" value="P2">P2</option>
<option className="ui-option" value="P3">P3</option>
<option className="ui-option" value="P4">P4</option>
</select>
</div>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'active' ? 'all' : 'active' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.preset === 'active'
? 'bg-[var(--accent-warning)]/15 border-[var(--accent-warning)]/40 text-[var(--accent-warning)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.preset === 'active'}
>
Active
</button>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, preset: filters.preset === 'blocked_agents' ? 'all' : 'blocked_agents' })}
className={cn(
'flex-1 rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.preset === 'blocked_agents'
? 'bg-[var(--accent-danger)]/15 border-[var(--accent-danger)]/40 text-[var(--accent-danger)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.preset === 'blocked_agents'}
>
Agent Blocked
</button>
</div>
<button
type="button"
onClick={() => onFiltersChange({ ...filters, hideClosed: !filters.hideClosed })}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.11em] transition-colors border',
filters.hideClosed
? 'bg-[var(--accent-success)]/15 border-[var(--accent-success)]/40 text-[var(--accent-success)]'
: 'bg-[var(--surface-quaternary)] border-[var(--border-subtle)] text-[var(--text-tertiary)]',
)}
aria-pressed={filters.hideClosed}
>
Hide Closed
</button>
</div>
<p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--text-tertiary)]">Navigation / Epics</p>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{entries.map((entry) => {
const {
epic,
children,
blockedCount,
activeCount,
readyCount,
deferredCount,
doneCount,
agentBlockedCount,
latestTimestamp,
} = entry;
const matchedChildren = children.filter((task) => isTaskMatch(task, filters));
const total = children.length;
const donePercent = total > 0 ? Math.round((doneCount / total) * 100) : 0;
const readyPercent = total > 0 ? Math.round((readyCount / total) * 100) : 0;
const activePercent = total > 0 ? Math.round((activeCount / total) * 100) : 0;
const blockedPercent = total > 0 ? Math.round((blockedCount / total) * 100) : 0;
const isExpanded = expanded[epic.id] ?? false;
const isSelected = selectedEpicId === epic.id;
const laneColor = blockedCount > 0 ? 'var(--accent-danger)' : activeCount > 0 ? 'var(--accent-warning)' : 'var(--accent-success)';
const rowBackground = rowTone(entry);
if (shouldHideEpicEntry({
epicStatus: epic.status,
matchedChildrenCount: matchedChildren.length,
totalChildrenCount: total,
isSelected,
filters,
})) {
return null;
}
return (
<div key={epic.id} className="mb-2">
<div
className={cn(
'rounded-xl px-3 py-3 transition-colors border border-[var(--border-subtle)]',
isSelected
? 'text-[var(--text-primary)] ring-1 ring-[var(--accent-info)]/30'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]',
)}
style={{
borderLeft: `3px solid ${laneColor}`,
background: rowBackground,
}}
>
<div className="mb-1.5 flex items-start gap-2">
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [epic.id]: !isExpanded }))}
className="mt-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label={isExpanded ? `Collapse ${epic.title}` : `Expand ${epic.title}`}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" /> : <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />}
</button>
<button
type="button"
onClick={() => onEpicSelect?.(isSelected ? null : epic.id)}
className="min-w-0 flex-1 text-left"
>
<div className="flex min-w-0 items-center gap-1.5">
{isExpanded ? <FolderOpen className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" /> : <Folder className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
<p className="truncate text-[15px] font-semibold leading-tight text-[var(--text-primary)]">{epic.title}</p>
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-[var(--text-tertiary)]">{epic.id}</p>
</button>
<button
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-[var(--surface-quaternary)] text-[var(--text-tertiary)] border border-[var(--border-subtle)] transition-colors hover:text-[var(--text-primary)]"
aria-label={`Focus ${epic.title}`}
>
<Star className="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onEpicSelect?.(epic.id); onAssignMode?.(epic.id); }}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 transition-colors hover:bg-emerald-500/20"
aria-label={`Launch Swarm for ${epic.title}`}
title="Launch Swarm"
>
<Rocket className="h-3 w-3" aria-hidden="true" />
</button>
{onEpicEdit && (
<button
type="button"
onClick={() => onEpicEdit(epic.id)}
className="inline-flex h-5 w-5 items-center justify-center rounded text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]"
aria-label={`Edit ${epic.title}`}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
<div className="flex items-center gap-3 text-[11px]">
<p><span className="text-[var(--text-primary)]">{total}</span> tasks</p>
<p><span className="text-[var(--accent-warning)]">{activeCount}</span> active</p>
<p><span className="text-[var(--accent-danger)]">{agentBlockedCount}</span> ag-blocked</p>
<p className="ml-auto text-[var(--text-tertiary)]">{formatRelative(latestTimestamp)}</p>
</div>
<div className="mt-2">
<div className="h-1.5 overflow-hidden rounded-full bg-[#0a111a]">
<div className="flex h-full w-full">
<div style={{ width: `${readyPercent}%`, background: 'var(--accent-success)' }} />
<div style={{ width: `${activePercent}%`, background: 'var(--accent-warning)' }} />
<div style={{ width: `${blockedPercent}%`, background: 'var(--accent-danger)' }} />
<div style={{ width: `${Math.max(0, 100 - readyPercent - activePercent - blockedPercent)}%`, background: 'var(--text-tertiary)' }} />
</div>
</div>
<div className="mt-1 flex items-center justify-between text-[10px] text-[var(--text-tertiary)]">
<span>{donePercent}% done</span>
<span><span className="text-[var(--accent-success)]">{readyCount}</span> ready</span>
</div>
</div>
{deferredCount + doneCount + blockedCount > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] text-[var(--text-tertiary)]">
{blockedCount > 0 ? <span>{blockedCount} blocked</span> : null}
{deferredCount > 0 ? <span>{deferredCount} deferred</span> : null}
{doneCount > 0 ? <span>{doneCount} done</span> : null}
</div>
) : null}
</div>
{isExpanded ? (
<div className="ml-8 mt-1 space-y-1 pl-3">
{matchedChildren.map((task) => (
<button
key={task.id}
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-tertiary)] hover:text-[var(--text-primary)]"
>
<span className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', statusDot(task.status))} />
<span className="min-w-0 flex-1 truncate">{task.title}</span>
{task.assignee ? (
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[8px] font-bold uppercase bg-[var(--alpha-white-low)] text-[var(--text-primary)]">
{task.assignee.slice(0, 2)}
</span>
) : null}
<span className="font-mono text-[10px] text-[var(--text-tertiary)] flex-shrink-0">{task.id}</span>
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
<footer className="border-t border-[var(--border-subtle)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-[linear-gradient(135deg,#9cb6bf,#f1dcc6)]" />
<div>
<p className="text-sm font-semibold text-[var(--text-primary)]">Alex Chen</p>
<p className="text-xs text-[var(--text-tertiary)]">Lead Ops</p>
</div>
</div>
</footer>
</aside>
);
}
export default LeftPanel;
return (
<div key={epic.id} className="mb-2">
<div
className={cn(
'rounded-xl px-3 py-3 transition-colors border border-[var(--border-subtle)]',
isSelected
? 'text-[var(--text-primary)] ring-1 ring-[var(--accent-info)]/30'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]',
)}
style={{
borderLeft: `3px solid ${laneColor}`,
background: rowBackground,
}}
>
<div className="mb-1.5 flex items-start gap-2">
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [epic.id]: !isExpanded }))}
className="mt-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label={isExpanded ? `Collapse ${epic.title}` : `Expand ${epic.title}`}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" /> : <ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />}
</button>
<button
type="button"
onClick={() => onEpicSelect?.(isSelected ? null : epic.id)}
className="min-w-0 flex-1 text-left"
>
<div className="flex min-w-0 items-center gap-1.5">
{isExpanded ? <FolderOpen className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" /> : <Folder className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
<p className="truncate text-[15px] font-semibold leading-tight text-[var(--text-primary)]">{epic.title}</p>
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-[var(--text-tertiary)]">{epic.id}</p>
</button>
<button
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-[var(--surface-quaternary)] text-[var(--text-tertiary)] border border-[var(--border-subtle)] transition-colors hover:text-[var(--text-primary)]"
aria-label={`Focus ${epic.title}`}
>
<Star className="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onEpicSelect?.(epic.id); onAssignMode?.(epic.id); }}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 transition-colors hover:bg-emerald-500/20"
aria-label={`Launch Swarm for ${epic.title}`}
title="Launch Swarm"
>
<Rocket className="h-3 w-3" aria-hidden="true" />
</button>
{onEpicEdit && (
<button
type="button"
onClick={() => onEpicEdit(epic.id)}
className="inline-flex h-5 w-5 items-center justify-center rounded text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]"
aria-label={`Edit ${epic.title}`}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
<div className="flex items-center gap-3 text-[11px]">
<p><span className="text-[var(--text-primary)]">{total}</span> tasks</p>
<p><span className="text-[var(--accent-warning)]">{activeCount}</span> active</p>
<p><span className="text-[var(--accent-danger)]">{agentBlockedCount}</span> ag-blocked</p>
<p className="ml-auto text-[var(--text-tertiary)]">{formatRelative(latestTimestamp)}</p>
</div>
<div className="mt-2">
<div className="h-1.5 overflow-hidden rounded-full bg-[#0a111a]">
<div className="flex h-full w-full">
<div style={{ width: `${readyPercent}%`, background: 'var(--accent-success)' }} />
<div style={{ width: `${activePercent}%`, background: 'var(--accent-warning)' }} />
<div style={{ width: `${blockedPercent}%`, background: 'var(--accent-danger)' }} />
<div style={{ width: `${Math.max(0, 100 - readyPercent - activePercent - blockedPercent)}%`, background: 'var(--text-tertiary)' }} />
</div>
</div>
<div className="mt-1 flex items-center justify-between text-[10px] text-[var(--text-tertiary)]">
<span>{donePercent}% done</span>
<span><span className="text-[var(--accent-success)]">{readyCount}</span> ready</span>
</div>
</div>
{deferredCount + doneCount + blockedCount > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] text-[var(--text-tertiary)]">
{blockedCount > 0 ? <span>{blockedCount} blocked</span> : null}
{deferredCount > 0 ? <span>{deferredCount} deferred</span> : null}
{doneCount > 0 ? <span>{doneCount} done</span> : null}
</div>
) : null}
</div>
{isExpanded ? (
<div className="ml-8 mt-1 space-y-1 pl-3">
{matchedChildren.map((task) => (
<button
key={task.id}
type="button"
onClick={() => onEpicSelect?.(epic.id)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-tertiary)] hover:text-[var(--text-primary)]"
>
<span className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', statusDot(task.status))} />
<span className="min-w-0 flex-1 truncate">{task.title}</span>
{task.assignee ? (
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[8px] font-bold uppercase bg-[var(--alpha-white-low)] text-[var(--text-primary)]">
{task.assignee.slice(0, 2)}
</span>
) : null}
<span className="font-mono text-[10px] text-[var(--text-tertiary)] flex-shrink-0">{task.id}</span>
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
<footer className="border-t border-[var(--border-subtle)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-[linear-gradient(135deg,#9cb6bf,#f1dcc6)]" />
<div>
<p className="text-sm font-semibold text-[var(--text-primary)]">Alex Chen</p>
<p className="text-xs text-[var(--text-tertiary)]">Lead Ops</p>
</div>
</div>
</footer>
</aside>
);
}
export default LeftPanel;

File diff suppressed because one or more lines are too long

View file

@ -1,150 +1,150 @@
'use client';
import { ReactNode } from 'react';
import { X } from 'lucide-react';
import { useResponsive } from '../../hooks/use-responsive';
import { useUrlState } from '../../hooks/use-url-state';
export interface RightPanelProps {
children?: ReactNode;
rail?: ReactNode;
isOpen?: boolean;
}
export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPanelProps) {
const { isMobile, isDesktop } = useResponsive();
const { rightPanel, toggleRightPanel } = useUrlState();
const isOpen = externalIsOpen ?? (rightPanel === 'open');
if (isDesktop) {
return (
<div
className="flex overflow-hidden h-full bg-[var(--surface-tertiary)] border-l border-[var(--border-strong)]"
style={{
boxShadow: isOpen ? '-8px 0 20px -12px rgba(0,0,0,0.4)' : 'none',
}}
data-testid="right-panel-desktop"
>
{isOpen && (
<>
{/* Main Content (Chat or Activity) */}
<div className="flex-1 min-w-0 h-full overflow-hidden flex flex-col">
<div className="border-l border-[var(--border-subtle)] bg-[var(--surface-tertiary)]">
<div className="px-3 py-2 border-b border-[var(--border-subtle)]">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Agent Pool Monitor</p>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-0 bg-[var(--surface-secondary)]">
{/* Remove default padding to allow edge-to-edge chat */}
{children || <span>Right Panel</span>}
</div>
</div>
{/* Side Rail (Mini Activity - Only if provided) */}
{rail && (
<div
className="h-full w-10 flex-shrink-0 shadow-[-10px_0_20px_-18px_rgba(0,0,0,0.9)]"
style={{
background: 'var(--surface-secondary)',
borderLeft: '1px solid var(--border-subtle)',
}}
>
{rail}
</div>
)}
</>
)}
</div>
);
}
if (!isOpen) {
return null;
}
const handleBackdropClick = () => {
toggleRightPanel();
};
const handleCloseClick = () => {
toggleRightPanel();
};
if (isMobile) {
return (
<div
className="fixed inset-0 z-50"
style={{
backgroundColor: 'var(--surface-tertiary)',
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
overscrollBehavior: 'contain',
touchAction: 'manipulation',
WebkitTapHighlightColor: 'rgba(0,0,0,0.08)',
}}
data-testid="right-panel-mobile"
>
<div className="flex justify-end px-4 py-3">
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-[var(--alpha-white-low)]"
style={{ color: 'var(--text-tertiary)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div
className="overflow-y-auto px-4 pb-4"
style={{
height: 'calc(100% - 4rem)',
color: 'var(--text-primary)',
overscrollBehavior: 'contain',
}}
>
{children || <span>Right Panel</span>}
</div>
</div>
);
}
// Tablet: slide-over
return (
<>
<div
className="fixed inset-0 z-40 bg-black/50"
onClick={handleBackdropClick}
data-testid="right-panel-backdrop"
/>
<div
className="fixed top-0 right-0 h-full z-50 overflow-y-auto"
style={{
width: '17rem',
background: 'var(--ui-bg-panel)',
borderLeft: '1px solid var(--ui-border-soft)',
boxShadow: '-12px 0 24px -16px rgba(0,0,0,0.5)',
}}
data-testid="right-panel-tablet"
>
<div className="flex justify-end p-4">
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-white/10"
style={{ color: 'var(--ui-text-muted)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div className="p-4" style={{ color: 'var(--ui-text-primary)' }}>
{children || <span>Right Panel</span>}
</div>
</div>
</>
);
}
export default RightPanel;
'use client';
import { ReactNode } from 'react';
import { X } from 'lucide-react';
import { useResponsive } from '../../hooks/use-responsive';
import { useUrlState } from '../../hooks/use-url-state';
export interface RightPanelProps {
children?: ReactNode;
rail?: ReactNode;
isOpen?: boolean;
}
export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPanelProps) {
const { isMobile, isDesktop } = useResponsive();
const { rightPanel, toggleRightPanel } = useUrlState();
const isOpen = externalIsOpen ?? (rightPanel === 'open');
if (isDesktop) {
return (
<div
className="flex overflow-hidden h-full bg-[var(--surface-tertiary)] border-l border-[var(--border-strong)]"
style={{
boxShadow: isOpen ? '-8px 0 20px -12px rgba(0,0,0,0.4)' : 'none',
}}
data-testid="right-panel-desktop"
>
{isOpen && (
<>
{/* Main Content (Chat or Activity) */}
<div className="flex-1 min-w-0 h-full overflow-hidden flex flex-col">
<div className="border-l border-[var(--border-subtle)] bg-[var(--surface-tertiary)]">
<div className="px-3 py-2 border-b border-[var(--border-subtle)]">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-tertiary)]">Agent Pool Monitor</p>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-0 bg-[var(--surface-secondary)]">
{/* Remove default padding to allow edge-to-edge chat */}
{children || <span>Right Panel</span>}
</div>
</div>
{/* Side Rail (Mini Activity - Only if provided) */}
{rail && (
<div
className="h-full w-10 flex-shrink-0 shadow-[-10px_0_20px_-18px_rgba(0,0,0,0.9)]"
style={{
background: 'var(--surface-secondary)',
borderLeft: '1px solid var(--border-subtle)',
}}
>
{rail}
</div>
)}
</>
)}
</div>
);
}
if (!isOpen) {
return null;
}
const handleBackdropClick = () => {
toggleRightPanel();
};
const handleCloseClick = () => {
toggleRightPanel();
};
if (isMobile) {
return (
<div
className="fixed inset-0 z-50"
style={{
backgroundColor: 'var(--surface-tertiary)',
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
overscrollBehavior: 'contain',
touchAction: 'manipulation',
WebkitTapHighlightColor: 'rgba(0,0,0,0.08)',
}}
data-testid="right-panel-mobile"
>
<div className="flex justify-end px-4 py-3">
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-[var(--alpha-white-low)]"
style={{ color: 'var(--text-tertiary)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div
className="overflow-y-auto px-4 pb-4"
style={{
height: 'calc(100% - 4rem)',
color: 'var(--text-primary)',
overscrollBehavior: 'contain',
}}
>
{children || <span>Right Panel</span>}
</div>
</div>
);
}
// Tablet: slide-over
return (
<>
<div
className="fixed inset-0 z-40 bg-black/50"
onClick={handleBackdropClick}
data-testid="right-panel-backdrop"
/>
<div
className="fixed top-0 right-0 h-full z-50 overflow-y-auto"
style={{
width: '17rem',
background: 'var(--ui-bg-panel)',
borderLeft: '1px solid var(--ui-border-soft)',
boxShadow: '-12px 0 24px -16px rgba(0,0,0,0.5)',
}}
data-testid="right-panel-tablet"
>
<div className="flex justify-end p-4">
<button
onClick={handleCloseClick}
className="p-2 rounded-md hover:bg-white/10"
style={{ color: 'var(--ui-text-muted)' }}
data-testid="right-panel-close"
aria-label="Close panel"
>
<X size={24} />
</button>
</div>
<div className="p-4" style={{ color: 'var(--ui-text-primary)' }}>
{children || <span>Right Panel</span>}
</div>
</div>
</>
);
}
export default RightPanel;

View file

@ -1,9 +1,9 @@
interface StatPillProps {
label: string;
value: number;
tone?: 'default' | 'critical';
}
interface StatPillProps {
label: string;
value: number;
tone?: 'default' | 'critical';
}
export function StatPill({ label, value, tone = 'default' }: StatPillProps) {
const valueToneClass = tone === 'critical' ? 'text-rose-300' : 'text-text-strong';

View file

@ -1,57 +1,57 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { BeadStatus } from '@/src/lib/types';
import type { SocialCardStatus } from '@/src/lib/social-cards';
type BadgeSize = 'sm' | 'md';
type StatusType = BeadStatus | SocialCardStatus;
interface StatusBadgeProps {
status: StatusType;
size?: BadgeSize;
}
const STATUS_CLASSES: Partial<Record<StatusType, string>> = {
open: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
ready: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
in_progress: 'border-green-500/30 bg-green-500/15 text-green-200',
blocked: 'border-amber-500/30 bg-amber-500/15 text-amber-200',
deferred: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
closed: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
pinned: 'border-purple-500/30 bg-purple-500/15 text-purple-200',
hooked: 'border-cyan-500/30 bg-cyan-500/15 text-cyan-200',
};
const SIZE_CLASSES: Record<BadgeSize, string> = {
sm: 'text-[10px] px-1.5 py-0.5',
md: 'text-xs px-2.5 py-0.5',
};
const STATUS_LABELS: Partial<Record<StatusType, string>> = {
open: 'Open',
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
closed: 'Closed',
pinned: 'Pinned',
hooked: 'Hooked',
};
export function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
const statusClass = STATUS_CLASSES[status] || 'border-slate-500/30 bg-slate-500/15 text-slate-300';
const statusLabel = STATUS_LABELS[status] || status;
return (
<Badge
variant="outline"
className={cn(
'rounded-md border font-semibold',
statusClass,
SIZE_CLASSES[size]
)}
>
{statusLabel}
</Badge>
);
}
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { BeadStatus } from '@/src/lib/types';
import type { SocialCardStatus } from '@/src/lib/social-cards';
type BadgeSize = 'sm' | 'md';
type StatusType = BeadStatus | SocialCardStatus;
interface StatusBadgeProps {
status: StatusType;
size?: BadgeSize;
}
const STATUS_CLASSES: Partial<Record<StatusType, string>> = {
open: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
ready: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
in_progress: 'border-green-500/30 bg-green-500/15 text-green-200',
blocked: 'border-amber-500/30 bg-amber-500/15 text-amber-200',
deferred: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
closed: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
pinned: 'border-purple-500/30 bg-purple-500/15 text-purple-200',
hooked: 'border-cyan-500/30 bg-cyan-500/15 text-cyan-200',
};
const SIZE_CLASSES: Record<BadgeSize, string> = {
sm: 'text-[10px] px-1.5 py-0.5',
md: 'text-xs px-2.5 py-0.5',
};
const STATUS_LABELS: Partial<Record<StatusType, string>> = {
open: 'Open',
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
closed: 'Closed',
pinned: 'Pinned',
hooked: 'Hooked',
};
export function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
const statusClass = STATUS_CLASSES[status] || 'border-slate-500/30 bg-slate-500/15 text-slate-300';
const statusLabel = STATUS_LABELS[status] || status;
return (
<Badge
variant="outline"
className={cn(
'rounded-md border font-semibold',
statusClass,
SIZE_CLASSES[size]
)}
>
{statusLabel}
</Badge>
);
}

View file

@ -1,63 +1,63 @@
export function statusGradient(status: string): string {
switch (status) {
case 'ready':
return 'border-l-2 border-emerald-400 bg-emerald-500/15';
case 'in_progress':
return 'border-l-2 border-amber-400 bg-amber-500/15';
case 'blocked':
return 'border-l-2 border-rose-400 bg-rose-500/15';
case 'closed':
return 'border-l-2 border-slate-400/60 bg-slate-500/10 opacity-80';
case 'open':
return 'border-l-2 border-sky-400 bg-sky-500/15';
case 'deferred':
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
default:
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
}
}
export function statusBorder(status: string): string {
switch (status) {
case 'ready':
case 'open':
return 'border-emerald-500/20';
case 'in_progress':
return 'border-amber-500/20';
case 'blocked':
return 'border-rose-500/20';
case 'closed':
return 'border-rose-500/30';
default:
return 'border-white/[0.06]';
}
}
export function statusDotColor(status: string): string {
switch (status) {
case 'ready':
case 'open':
return 'bg-emerald-400';
case 'in_progress':
return 'bg-amber-400';
case 'blocked':
return 'bg-rose-400';
case 'closed':
return 'bg-slate-400';
default:
return 'bg-slate-400';
}
}
export function sessionStateGlow(state: string): string {
switch (state) {
case 'active': return 'shadow-[0_0_12px_rgba(74,222,128,0.3)] border-emerald-500/30';
case 'needs_input': return 'shadow-[0_0_12px_rgba(248,113,113,0.3)] border-rose-500/30';
case 'stuck': return 'ring-2 ring-red-500 animate-pulse shadow-[0_0_16px_rgba(239,68,68,0.5)]';
case 'dead': return 'opacity-40 grayscale';
case 'evicted': return 'opacity-60 grayscale-[0.5]';
case 'stale': return 'opacity-60 grayscale-[0.5]';
case 'completed': return 'opacity-80';
default: return '';
}
}
export function statusGradient(status: string): string {
switch (status) {
case 'ready':
return 'border-l-2 border-emerald-400 bg-emerald-500/15';
case 'in_progress':
return 'border-l-2 border-amber-400 bg-amber-500/15';
case 'blocked':
return 'border-l-2 border-rose-400 bg-rose-500/15';
case 'closed':
return 'border-l-2 border-slate-400/60 bg-slate-500/10 opacity-80';
case 'open':
return 'border-l-2 border-sky-400 bg-sky-500/15';
case 'deferred':
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
default:
return 'border-l-2 border-slate-400/60 bg-slate-500/10';
}
}
export function statusBorder(status: string): string {
switch (status) {
case 'ready':
case 'open':
return 'border-emerald-500/20';
case 'in_progress':
return 'border-amber-500/20';
case 'blocked':
return 'border-rose-500/20';
case 'closed':
return 'border-rose-500/30';
default:
return 'border-white/[0.06]';
}
}
export function statusDotColor(status: string): string {
switch (status) {
case 'ready':
case 'open':
return 'bg-emerald-400';
case 'in_progress':
return 'bg-amber-400';
case 'blocked':
return 'bg-rose-400';
case 'closed':
return 'bg-slate-400';
default:
return 'bg-slate-400';
}
}
export function sessionStateGlow(state: string): string {
switch (state) {
case 'active': return 'shadow-[0_0_12px_rgba(74,222,128,0.3)] border-emerald-500/30';
case 'needs_input': return 'shadow-[0_0_12px_rgba(248,113,113,0.3)] border-rose-500/30';
case 'stuck': return 'ring-2 ring-red-500 animate-pulse shadow-[0_0_16px_rgba(239,68,68,0.5)]';
case 'dead': return 'opacity-40 grayscale';
case 'evicted': return 'opacity-60 grayscale-[0.5]';
case 'stale': return 'opacity-60 grayscale-[0.5]';
case 'completed': return 'opacity-80';
default: return '';
}
}

View file

@ -1,74 +1,74 @@
'use client';
import { useEffect, useState } from 'react';
import { Signal } from 'lucide-react';
import type { ActivityEvent } from '../../lib/activity';
import { getEventTone } from '../activity/activity-panel';
import { cn } from '../../lib/utils';
interface TelemetryStripProps {
projectRoot: string;
onMaximize: () => void;
}
export function TelemetryStrip({ projectRoot, onMaximize }: TelemetryStripProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
// Fetch initial activity history
useEffect(() => {
async function fetchActivity() {
try {
const response = await fetch('/api/activity');
if (response.ok) {
const data = await response.json();
setActivities(data.slice(0, 12));
}
} catch { /* ignore */ }
}
fetchActivity();
}, []);
// Subscribe to real-time activity via SSE
useEffect(() => {
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
const onActivity = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (data?.beadId) {
setActivities(prev => [data, ...prev].slice(0, 12));
}
} catch { /* ignore */ }
};
source.addEventListener('activity', onActivity as EventListener);
return () => { source.removeEventListener('activity', onActivity as EventListener); source.close(); };
}, [projectRoot]);
return (
<div className="flex h-full w-9 flex-shrink-0 flex-col items-center border-l border-[var(--border-subtle)] bg-[var(--surface-primary)] py-2">
<button
type="button"
onClick={onMaximize}
className="mb-2 rounded p-1 text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
title="Restore live feed"
aria-label="Restore live feed"
>
<Signal className="h-3.5 w-3.5" />
</button>
<div className="flex flex-1 flex-col items-center gap-2 overflow-hidden">
{activities.map((act) => {
const tone = getEventTone(act.kind);
return (
<div
key={act.id}
className="flex flex-col items-center"
title={`${act.beadId}: ${act.beadTitle} (${tone.label})`}
>
<span className={cn('h-2 w-2 rounded-full', tone.dotClass)} />
</div>
);
})}
</div>
</div>
);
}
'use client';
import { useEffect, useState } from 'react';
import { Signal } from 'lucide-react';
import type { ActivityEvent } from '../../lib/activity';
import { getEventTone } from '../activity/activity-panel';
import { cn } from '../../lib/utils';
interface TelemetryStripProps {
projectRoot: string;
onMaximize: () => void;
}
export function TelemetryStrip({ projectRoot, onMaximize }: TelemetryStripProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
// Fetch initial activity history
useEffect(() => {
async function fetchActivity() {
try {
const response = await fetch('/api/activity');
if (response.ok) {
const data = await response.json();
setActivities(data.slice(0, 12));
}
} catch { /* ignore */ }
}
fetchActivity();
}, []);
// Subscribe to real-time activity via SSE
useEffect(() => {
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
const onActivity = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (data?.beadId) {
setActivities(prev => [data, ...prev].slice(0, 12));
}
} catch { /* ignore */ }
};
source.addEventListener('activity', onActivity as EventListener);
return () => { source.removeEventListener('activity', onActivity as EventListener); source.close(); };
}, [projectRoot]);
return (
<div className="flex h-full w-9 flex-shrink-0 flex-col items-center border-l border-[var(--border-subtle)] bg-[var(--surface-primary)] py-2">
<button
type="button"
onClick={onMaximize}
className="mb-2 rounded p-1 text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)]"
title="Restore live feed"
aria-label="Restore live feed"
>
<Signal className="h-3.5 w-3.5" />
</button>
<div className="flex flex-1 flex-col items-center gap-2 overflow-hidden">
{activities.map((act) => {
const tone = getEventTone(act.kind);
return (
<div
key={act.id}
className="flex flex-col items-center"
title={`${act.beadId}: ${act.beadTitle} (${tone.label})`}
>
<span className={cn('h-2 w-2 rounded-full', tone.dotClass)} />
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,85 +1,85 @@
'use client';
import { useState, useEffect } from 'react';
import { Palette, Check } from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
const themes = [
{ id: 'aurora', name: 'Aurora', desc: 'Northern lights' },
{ id: 'contrast', name: 'Contrast', desc: 'High contrast neon' },
{ id: 'light', name: 'Light', desc: 'Soft grey' },
] as const;
export function ThemeToggle() {
const [currentTheme, setCurrentTheme] = useState<string>('aurora');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Load saved theme from localStorage
const saved = localStorage.getItem('beadboard-theme');
if (saved && themes.find(t => t.id === saved)) {
setCurrentTheme(saved);
document.documentElement.setAttribute('data-theme', saved);
}
}, []);
const handleThemeChange = (themeId: string) => {
setCurrentTheme(themeId);
document.documentElement.setAttribute('data-theme', themeId);
localStorage.setItem('beadboard-theme', themeId);
};
// Prevent hydration mismatch
if (!mounted) {
return (
<button className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)]">
<Palette className="h-4 w-4" />
</button>
);
}
const current = themes.find(t => t.id === currentTheme) || themes[0];
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label="Change theme"
>
<Palette className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-overlay)] p-2 shadow-[var(--shadow-lg)] backdrop-blur-lg z-50"
sideOffset={8}
align="end"
>
<div className="px-2 py-1.5 mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)]">Theme</p>
<p className="text-[10px] text-[var(--text-tertiary)]">{current.desc}</p>
</div>
<DropdownMenu.Separator className="h-px bg-[var(--border-subtle)] my-1" />
{themes.map((theme) => (
<DropdownMenu.Item
key={theme.id}
onClick={() => handleThemeChange(theme.id)}
className="flex items-center justify-between rounded-lg px-2 py-2 text-xs text-[var(--text-secondary)] hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] cursor-pointer outline-none transition-colors"
>
<span>{theme.name}</span>
{currentTheme === theme.id && (
<Check className="h-3.5 w-3.5 text-[var(--accent-success)]" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
'use client';
import { useState, useEffect } from 'react';
import { Palette, Check } from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
const themes = [
{ id: 'aurora', name: 'Aurora', desc: 'Northern lights' },
{ id: 'contrast', name: 'Contrast', desc: 'High contrast neon' },
{ id: 'light', name: 'Light', desc: 'Soft grey' },
] as const;
export function ThemeToggle() {
const [currentTheme, setCurrentTheme] = useState<string>('aurora');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Load saved theme from localStorage
const saved = localStorage.getItem('beadboard-theme');
if (saved && themes.find(t => t.id === saved)) {
setCurrentTheme(saved);
document.documentElement.setAttribute('data-theme', saved);
}
}, []);
const handleThemeChange = (themeId: string) => {
setCurrentTheme(themeId);
document.documentElement.setAttribute('data-theme', themeId);
localStorage.setItem('beadboard-theme', themeId);
};
// Prevent hydration mismatch
if (!mounted) {
return (
<button className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)]">
<Palette className="h-4 w-4" />
</button>
);
}
const current = themes.find(t => t.id === currentTheme) || themes[0];
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label="Change theme"
>
<Palette className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-overlay)] p-2 shadow-[var(--shadow-lg)] backdrop-blur-lg z-50"
sideOffset={8}
align="end"
>
<div className="px-2 py-1.5 mb-1">
<p className="text-xs font-semibold text-[var(--text-primary)]">Theme</p>
<p className="text-[10px] text-[var(--text-tertiary)]">{current.desc}</p>
</div>
<DropdownMenu.Separator className="h-px bg-[var(--border-subtle)] my-1" />
{themes.map((theme) => (
<DropdownMenu.Item
key={theme.id}
onClick={() => handleThemeChange(theme.id)}
className="flex items-center justify-between rounded-lg px-2 py-2 text-xs text-[var(--text-secondary)] hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] cursor-pointer outline-none transition-colors"
>
<span>{theme.name}</span>
{currentTheme === theme.id && (
<Check className="h-3.5 w-3.5 text-[var(--accent-success)]" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,181 +1,181 @@
'use client';
import { ArrowRight, Ban, CheckCircle2, MessageSquare, UserMinus } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
export type ThreadItemType = 'comment' | 'status_change' | 'protocol_event';
export interface ThreadItem {
id: string;
type: ThreadItemType;
author?: string;
content?: string;
from?: string;
to?: string;
event?: string;
timestamp: Date;
}
interface ThreadViewProps {
items: ThreadItem[];
variant?: 'stack' | 'chat';
currentUser?: string;
onAddComment?: (text: string) => void;
}
function getInitials(name: string): string {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
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' });
}
function getProtocolIcon(event?: string) {
switch (event?.toUpperCase()) {
case 'HANDOFF':
return <UserMinus className="w-4 h-4 text-amber-400" />;
case 'BLOCKED':
return <Ban className="w-4 h-4 text-rose-400" />;
case 'CLOSED':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
default:
return <MessageSquare className="w-4 h-4 text-text-muted" />;
}
}
function getProtocolLabel(event?: string): string {
switch (event?.toUpperCase()) {
case 'HANDOFF':
return 'Handoff';
case 'BLOCKED':
return 'Blocked';
case 'CLOSED':
return 'Closed';
default:
return 'Event';
}
}
function CommentItem({ item, isSelf }: { item: ThreadItem; isSelf: boolean }) {
return (
<div className={cn('flex gap-3 py-3', isSelf && 'justify-end')}>
{!isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={undefined} alt={item.author} />
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : '??'}
</AvatarFallback>
</Avatar>
) : null}
<div className={cn('min-w-0 max-w-[80%]', isSelf && 'items-end')}>
<div className={cn('mb-1 flex items-center gap-2', isSelf && 'justify-end')}>
<span className="text-text-primary text-sm font-semibold">{item.author || 'Unknown'}</span>
<span className="font-mono text-[11px] text-text-muted">{formatRelativeTime(item.timestamp)}</span>
</div>
<p
className={cn(
'whitespace-pre-wrap break-words rounded-xl px-3 py-2 text-base leading-relaxed',
isSelf
? 'bg-[color-mix(in_srgb,var(--ui-accent-ready)_24%,var(--ui-bg-panel))] text-[var(--ui-text-primary)]'
: 'bg-[color-mix(in_srgb,var(--ui-bg-panel)_88%,black)] text-text-secondary',
)}
>
{item.content}
</p>
</div>
{isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarFallback className="bg-[color-mix(in_srgb,var(--ui-accent-ready)_40%,var(--ui-bg-panel))] text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : 'ME'}
</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
function StatusChangeItem({ item }: { item: ThreadItem }) {
return (
<div className="flex items-center gap-3 py-2 text-sm">
<ArrowRight className="w-4 h-4 text-text-muted flex-shrink-0" />
<span className="text-text-muted">
Status: <span className="text-text-primary font-medium">{item.from || 'unknown'}</span>
<ArrowRight className="w-3 h-3 inline mx-1 text-text-muted" />
<span className="text-text-primary font-medium">{item.to || 'unknown'}</span>
</span>
<span className="text-text-muted text-xs ml-auto">
{formatRelativeTime(item.timestamp)}
</span>
</div>
);
}
function ProtocolEventItem({ item }: { item: ThreadItem }) {
return (
<div className="flex items-center gap-3 py-2">
<div className="flex-shrink-0">{getProtocolIcon(item.event)}</div>
<div className="flex-1 min-w-0">
<span className="text-text-primary text-sm font-medium">
{getProtocolLabel(item.event)}
</span>
{item.content && (
<span className="text-text-secondary text-sm ml-2">{item.content}</span>
)}
</div>
<span className="text-text-muted text-xs">
{formatRelativeTime(item.timestamp)}
</span>
</div>
);
}
export function ThreadView({ items, variant = 'stack', currentUser = 'you', onAddComment }: ThreadViewProps) {
void onAddComment;
return (
<div className="space-y-1">
{items.length === 0 ? (
<p className="text-text-muted text-sm italic py-4">No activity yet</p>
) : (
<div className="divide-y divide-white/5">
{items.map((item) => {
switch (item.type) {
case 'comment':
return (
<CommentItem
key={item.id}
item={item}
isSelf={variant === 'chat' && (item.author ?? '').trim().toLowerCase() === currentUser.toLowerCase()}
/>
);
case 'status_change':
return <StatusChangeItem key={item.id} item={item} />;
case 'protocol_event':
return <ProtocolEventItem key={item.id} item={item} />;
default:
return null;
}
})}
</div>
)}
</div>
);
}
'use client';
import { ArrowRight, Ban, CheckCircle2, MessageSquare, UserMinus } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
export type ThreadItemType = 'comment' | 'status_change' | 'protocol_event';
export interface ThreadItem {
id: string;
type: ThreadItemType;
author?: string;
content?: string;
from?: string;
to?: string;
event?: string;
timestamp: Date;
}
interface ThreadViewProps {
items: ThreadItem[];
variant?: 'stack' | 'chat';
currentUser?: string;
onAddComment?: (text: string) => void;
}
function getInitials(name: string): string {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
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' });
}
function getProtocolIcon(event?: string) {
switch (event?.toUpperCase()) {
case 'HANDOFF':
return <UserMinus className="w-4 h-4 text-amber-400" />;
case 'BLOCKED':
return <Ban className="w-4 h-4 text-rose-400" />;
case 'CLOSED':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
default:
return <MessageSquare className="w-4 h-4 text-text-muted" />;
}
}
function getProtocolLabel(event?: string): string {
switch (event?.toUpperCase()) {
case 'HANDOFF':
return 'Handoff';
case 'BLOCKED':
return 'Blocked';
case 'CLOSED':
return 'Closed';
default:
return 'Event';
}
}
function CommentItem({ item, isSelf }: { item: ThreadItem; isSelf: boolean }) {
return (
<div className={cn('flex gap-3 py-3', isSelf && 'justify-end')}>
{!isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={undefined} alt={item.author} />
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : '??'}
</AvatarFallback>
</Avatar>
) : null}
<div className={cn('min-w-0 max-w-[80%]', isSelf && 'items-end')}>
<div className={cn('mb-1 flex items-center gap-2', isSelf && 'justify-end')}>
<span className="text-text-primary text-sm font-semibold">{item.author || 'Unknown'}</span>
<span className="font-mono text-[11px] text-text-muted">{formatRelativeTime(item.timestamp)}</span>
</div>
<p
className={cn(
'whitespace-pre-wrap break-words rounded-xl px-3 py-2 text-base leading-relaxed',
isSelf
? 'bg-[color-mix(in_srgb,var(--ui-accent-ready)_24%,var(--ui-bg-panel))] text-[var(--ui-text-primary)]'
: 'bg-[color-mix(in_srgb,var(--ui-bg-panel)_88%,black)] text-text-secondary',
)}
>
{item.content}
</p>
</div>
{isSelf ? (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarFallback className="bg-[color-mix(in_srgb,var(--ui-accent-ready)_40%,var(--ui-bg-panel))] text-text-body text-xs font-semibold">
{item.author ? getInitials(item.author) : 'ME'}
</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
function StatusChangeItem({ item }: { item: ThreadItem }) {
return (
<div className="flex items-center gap-3 py-2 text-sm">
<ArrowRight className="w-4 h-4 text-text-muted flex-shrink-0" />
<span className="text-text-muted">
Status: <span className="text-text-primary font-medium">{item.from || 'unknown'}</span>
<ArrowRight className="w-3 h-3 inline mx-1 text-text-muted" />
<span className="text-text-primary font-medium">{item.to || 'unknown'}</span>
</span>
<span className="text-text-muted text-xs ml-auto">
{formatRelativeTime(item.timestamp)}
</span>
</div>
);
}
function ProtocolEventItem({ item }: { item: ThreadItem }) {
return (
<div className="flex items-center gap-3 py-2">
<div className="flex-shrink-0">{getProtocolIcon(item.event)}</div>
<div className="flex-1 min-w-0">
<span className="text-text-primary text-sm font-medium">
{getProtocolLabel(item.event)}
</span>
{item.content && (
<span className="text-text-secondary text-sm ml-2">{item.content}</span>
)}
</div>
<span className="text-text-muted text-xs">
{formatRelativeTime(item.timestamp)}
</span>
</div>
);
}
export function ThreadView({ items, variant = 'stack', currentUser = 'you', onAddComment }: ThreadViewProps) {
void onAddComment;
return (
<div className="space-y-1">
{items.length === 0 ? (
<p className="text-text-muted text-sm italic py-4">No activity yet</p>
) : (
<div className="divide-y divide-white/5">
{items.map((item) => {
switch (item.type) {
case 'comment':
return (
<CommentItem
key={item.id}
item={item}
isSelf={variant === 'chat' && (item.author ?? '').trim().toLowerCase() === currentUser.toLowerCase()}
/>
);
case 'status_change':
return <StatusChangeItem key={item.id} item={item} />;
case 'protocol_event':
return <ProtocolEventItem key={item.id} item={item} />;
default:
return null;
}
})}
</div>
)}
</div>
);
}

View file

@ -1,11 +1,11 @@
'use client';
import { ReactNode, useState } from 'react';
import { LayoutGrid, Lock, Plus, Rocket, Sidebar, SidebarClose } from 'lucide-react';
import { useUrlState } from '../../hooks/use-url-state';
import { useResponsive } from '../../hooks/use-responsive';
import { ThemeToggle } from './theme-toggle';
'use client';
import { ReactNode, useState } from 'react';
import { LayoutGrid, Lock, Plus, Rocket, Sidebar, SidebarClose } from 'lucide-react';
import { useUrlState } from '../../hooks/use-url-state';
import { useResponsive } from '../../hooks/use-responsive';
import { ThemeToggle } from './theme-toggle';
export interface TopBarProps {
onCreateTask?: () => Promise<void> | void;
isCreatingTask?: boolean;
@ -20,62 +20,62 @@ export interface TopBarProps {
onLaunchSwarm?: () => void;
onOpenBlockedTriage?: () => void;
}
interface MetricTileProps {
label: string;
value: number;
accent?: 'ready' | 'blocked' | 'info' | 'warning';
}
function MetricTile({ label, value, accent = 'info' }: MetricTileProps) {
const accentColor =
accent === 'ready'
? 'var(--accent-success)'
: accent === 'blocked'
? 'var(--accent-danger)'
: accent === 'warning'
? 'var(--accent-warning)'
: 'var(--accent-info)';
return (
<div className="hidden items-center gap-2 rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-1 text-xs md:inline-flex">
<p className="font-mono text-[10px] uppercase tracking-[0.13em] text-[var(--text-tertiary)]">{label}</p>
<p className="font-mono text-sm leading-none text-[var(--text-primary)]">{value}</p>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
</div>
);
}
function IdentityChip({ actor, onActorChange }: { actor: string; onActorChange: (name: string) => void }) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<input
autoFocus
type="text"
value={actor}
onChange={e => onActorChange(e.target.value)}
onBlur={() => setEditing(false)}
onKeyDown={e => { if (e.key === 'Enter') setEditing(false); }}
placeholder="your name"
className="h-7 w-28 rounded-full border border-[var(--accent-info)] bg-[var(--surface-tertiary)] px-3 text-xs text-[var(--text-primary)] outline-none"
/>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
title="Set your operator name"
className="inline-flex h-7 items-center rounded-full border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-3 text-xs text-[var(--text-secondary)] transition-colors hover:border-[var(--accent-info)] hover:text-[var(--text-primary)]"
>
{actor || <span className="text-[var(--text-tertiary)]">your name</span>}
</button>
);
}
interface MetricTileProps {
label: string;
value: number;
accent?: 'ready' | 'blocked' | 'info' | 'warning';
}
function MetricTile({ label, value, accent = 'info' }: MetricTileProps) {
const accentColor =
accent === 'ready'
? 'var(--accent-success)'
: accent === 'blocked'
? 'var(--accent-danger)'
: accent === 'warning'
? 'var(--accent-warning)'
: 'var(--accent-info)';
return (
<div className="hidden items-center gap-2 rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-1 text-xs md:inline-flex">
<p className="font-mono text-[10px] uppercase tracking-[0.13em] text-[var(--text-tertiary)]">{label}</p>
<p className="font-mono text-sm leading-none text-[var(--text-primary)]">{value}</p>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
</div>
);
}
function IdentityChip({ actor, onActorChange }: { actor: string; onActorChange: (name: string) => void }) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<input
autoFocus
type="text"
value={actor}
onChange={e => onActorChange(e.target.value)}
onBlur={() => setEditing(false)}
onKeyDown={e => { if (e.key === 'Enter') setEditing(false); }}
placeholder="your name"
className="h-7 w-28 rounded-full border border-[var(--accent-info)] bg-[var(--surface-tertiary)] px-3 text-xs text-[var(--text-primary)] outline-none"
/>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
title="Set your operator name"
className="inline-flex h-7 items-center rounded-full border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-3 text-xs text-[var(--text-secondary)] transition-colors hover:border-[var(--accent-info)] hover:text-[var(--text-primary)]"
>
{actor || <span className="text-[var(--text-tertiary)]">your name</span>}
</button>
);
}
export function TopBar({
onCreateTask,
isCreatingTask = false,
@ -90,117 +90,117 @@ export function TopBar({
onLaunchSwarm,
onOpenBlockedTriage,
}: TopBarProps) {
const { leftPanel, toggleLeftPanel, rightPanel, toggleRightPanel, blockedOnly, toggleBlockedOnly } = useUrlState();
const { isDesktop } = useResponsive();
return (
<header className="flex h-[var(--topbar-height)] items-center justify-between border-b border-[var(--border-strong)] bg-[var(--surface-elevated)]" data-testid="top-bar">
<div className="flex min-w-0 items-center">
<button
type="button"
onClick={toggleLeftPanel}
className="ml-3 mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label={leftPanel === 'open' ? 'Collapse Sidebar' : 'Expand Sidebar'}
aria-pressed={leftPanel === 'open'}
data-testid="hamburger-button"
>
{leftPanel === 'open' ? <SidebarClose className="h-4 w-4" aria-hidden="true" /> : <Sidebar className="h-4 w-4" aria-hidden="true" />}
</button>
<div className="mr-3 flex min-w-[210px] items-center gap-2 border-r border-[var(--border-subtle)] px-2 py-2">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-[var(--surface-quaternary)] text-[var(--accent-success)]">
<LayoutGrid className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.04em] text-[var(--text-primary)]">Command Grid</p>
<p className="font-mono text-[10px] text-[var(--text-tertiary)]">v2.4.0-stable</p>
</div>
</div>
<div className="hidden items-center gap-2 pl-2 md:flex">
<MetricTile label="Total" value={totalTasks} accent="ready" />
<MetricTile label="Blocked" value={criticalAlerts} accent="blocked" />
<MetricTile label="Busy" value={busyCount} accent="warning" />
<MetricTile label="Idle" value={idleCount} accent="info" />
</div>
</div>
<div className="mr-3 flex items-center gap-2">
{children ?? (
<>
const { leftPanel, toggleLeftPanel, rightPanel, toggleRightPanel, blockedOnly, toggleBlockedOnly } = useUrlState();
const { isDesktop } = useResponsive();
return (
<header className="flex h-[var(--topbar-height)] items-center justify-between border-b border-[var(--border-strong)] bg-[var(--surface-elevated)]" data-testid="top-bar">
<div className="flex min-w-0 items-center">
<button
type="button"
onClick={toggleLeftPanel}
className="ml-3 mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label={leftPanel === 'open' ? 'Collapse Sidebar' : 'Expand Sidebar'}
aria-pressed={leftPanel === 'open'}
data-testid="hamburger-button"
>
{leftPanel === 'open' ? <SidebarClose className="h-4 w-4" aria-hidden="true" /> : <Sidebar className="h-4 w-4" aria-hidden="true" />}
</button>
<div className="mr-3 flex min-w-[210px] items-center gap-2 border-r border-[var(--border-subtle)] px-2 py-2">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-[var(--surface-quaternary)] text-[var(--accent-success)]">
<LayoutGrid className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.04em] text-[var(--text-primary)]">Command Grid</p>
<p className="font-mono text-[10px] text-[var(--text-tertiary)]">v2.4.0-stable</p>
</div>
</div>
<div className="hidden items-center gap-2 pl-2 md:flex">
<MetricTile label="Total" value={totalTasks} accent="ready" />
<MetricTile label="Blocked" value={criticalAlerts} accent="blocked" />
<MetricTile label="Busy" value={busyCount} accent="warning" />
<MetricTile label="Idle" value={idleCount} accent="info" />
</div>
</div>
<div className="mr-3 flex items-center gap-2">
{children ?? (
<>
<button
type="button"
onClick={onOpenBlockedTriage}
aria-pressed={blockedOnly}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.11em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
style={{
borderColor: blockedOnly
? 'var(--accent-danger)'
: 'var(--border-default)',
backgroundColor: blockedOnly
? 'var(--status-blocked)'
: 'var(--surface-tertiary)',
color: blockedOnly ? '#ffd4dd' : 'var(--text-primary)',
}}
data-testid="blocked-items-button"
>
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Blocked Items
<span className="rounded-full bg-[var(--accent-danger)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--text-inverse)]">
{criticalAlerts}
</span>
</button>
<button
type="button"
onClick={() => {
void onCreateTask?.();
}}
disabled={isCreatingTask}
className="inline-flex items-center gap-2 rounded-xl border border-[var(--accent-success)] bg-[var(--accent-success)] px-4 py-2 text-xs font-semibold uppercase tracking-[0.11em] text-[var(--text-inverse)] transition-colors hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)] disabled:opacity-60"
data-testid="new-task-button"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{isCreatingTask ? 'Creating…' : 'New Task'}
</button>
</>
)}
{onLaunchSwarm ? (
<button
type="button"
onClick={onLaunchSwarm}
className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.11em] text-emerald-400 transition-colors hover:bg-emerald-500/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label="Launch Swarm"
>
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
Launch Swarm
</button>
) : null}
{onActorChange ? <IdentityChip actor={actor} onActorChange={onActorChange} /> : null}
<ThemeToggle />
{isDesktop ? (
<button
type="button"
onClick={toggleRightPanel}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label={rightPanel === 'open' ? 'Collapse Right Sidebar' : 'Expand Right Sidebar'}
aria-pressed={rightPanel === 'open'}
data-testid="settings-button"
>
<Sidebar className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
<span className="sr-only" aria-live="polite">
{taskActionMessage ?? ''}
</span>
</div>
</header>
);
}
export default TopBar;
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.11em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
style={{
borderColor: blockedOnly
? 'var(--accent-danger)'
: 'var(--border-default)',
backgroundColor: blockedOnly
? 'var(--status-blocked)'
: 'var(--surface-tertiary)',
color: blockedOnly ? '#ffd4dd' : 'var(--text-primary)',
}}
data-testid="blocked-items-button"
>
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Blocked Items
<span className="rounded-full bg-[var(--accent-danger)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--text-inverse)]">
{criticalAlerts}
</span>
</button>
<button
type="button"
onClick={() => {
void onCreateTask?.();
}}
disabled={isCreatingTask}
className="inline-flex items-center gap-2 rounded-xl border border-[var(--accent-success)] bg-[var(--accent-success)] px-4 py-2 text-xs font-semibold uppercase tracking-[0.11em] text-[var(--text-inverse)] transition-colors hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)] disabled:opacity-60"
data-testid="new-task-button"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{isCreatingTask ? 'Creating…' : 'New Task'}
</button>
</>
)}
{onLaunchSwarm ? (
<button
type="button"
onClick={onLaunchSwarm}
className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.11em] text-emerald-400 transition-colors hover:bg-emerald-500/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label="Launch Swarm"
>
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
Launch Swarm
</button>
) : null}
{onActorChange ? <IdentityChip actor={actor} onActorChange={onActorChange} /> : null}
<ThemeToggle />
{isDesktop ? (
<button
type="button"
onClick={toggleRightPanel}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
aria-label={rightPanel === 'open' ? 'Collapse Right Sidebar' : 'Expand Right Sidebar'}
aria-pressed={rightPanel === 'open'}
data-testid="settings-button"
>
<Sidebar className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
<span className="sr-only" aria-live="polite">
{taskActionMessage ?? ''}
</span>
</div>
</header>
);
}
export default TopBar;

View file

@ -156,10 +156,23 @@ export function UnifiedShell({
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId || !!epicId);
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
const drawerId = taskId || swarmId || epicId || '';
const selectedItem = selectedEpic ?? selectedIssue;
const selectedEpic = epicId ? issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic') ?? null : null;
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || selectedEpic?.title || '';
const drawerId = taskId || swarmId || epicId || '';
const selectedItem = selectedEpic ?? selectedIssue;
useEffect(() => {
if (!filters.hideClosed || !epicId) {
return;
}
const epic = issues.find((issue) => issue.id === epicId && issue.issue_type === 'epic');
if (!epic) {
return;
}
if (epic.status === 'closed' || epic.status === 'tombstone') {
setEpicId(null);
}
}, [filters.hideClosed, epicId, issues, setEpicId]);
// Panel resize hook
const { leftWidth, rightWidth, handleLeftResize, handleRightResize } = usePanelResize();

View file

@ -1,361 +1,459 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import { Maximize2 } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
import { useGraphAnalysis } from '../../hooks/use-graph-analysis';
import { GraphNodeCard, type GraphNodeData } from '../graph/graph-node-card';
export interface WorkflowGraphProps {
beads: BeadIssue[];
selectedId?: string;
onSelect?: (id: string) => void;
onViewInSocial?: (id: string) => void;
onAssignMode?: (id: string) => void;
onViewTelemetry?: (id: string) => void;
className?: string;
hideClosed?: boolean;
archetypes?: AgentArchetype[];
assignMode?: boolean;
swarmId?: string;
}
const NODE_WIDTH = 320;
const NODE_HEIGHT = 150;
type LayoutDirection = 'LR' | 'TB';
type LayoutDensity = 'normal' | 'compact';
function layoutDagre(
nodes: Node<GraphNodeData>[],
edges: Edge[],
direction: LayoutDirection,
density: LayoutDensity,
): Node<GraphNodeData>[] {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: direction,
ranksep: density === 'compact' ? 70 : 120,
nodesep: density === 'compact' ? 35 : 70,
});
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
};
});
}
function WorkflowGraphInner({
beads,
selectedId,
onSelect,
onViewInSocial,
onAssignMode,
onViewTelemetry,
className = '',
hideClosed = false,
archetypes = [],
assignMode = false,
}: WorkflowGraphProps) {
const { fitView } = useReactFlow();
const [layoutDirection, setLayoutDirection] = useState<LayoutDirection>('LR');
const [layoutDensity, setLayoutDensity] = useState<LayoutDensity>('normal');
// Use the extracted hook for all graph analysis
const {
signalById,
cycleNodeIdSet,
actionableNodeIds,
blockerTooltipMap,
blockerAnalysis,
chainNodeIds,
} = useGraphAnalysis(beads, 'workflow', selectedId);
const flowModel = useMemo(() => {
const visibleBeads = beads.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'));
if (visibleBeads.length === 0) {
return { nodes: [] as Node<GraphNodeData>[], edges: [] as Edge[] };
}
const sourcePosition = layoutDirection === 'TB' ? Position.Bottom : Position.Right;
const targetPosition = layoutDirection === 'TB' ? Position.Top : Position.Left;
const baseNodes: Node<GraphNodeData>[] = visibleBeads.map((issue) => {
let matchedArchetype: AgentArchetype | undefined;
if (archetypes && issue.assignee) {
const assigneeStr = issue.assignee.toLowerCase();
matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
}
return {
id: issue.id,
data: {
title: issue.title,
kind: 'issue' as const,
status: issue.status,
priority: issue.priority,
blockedBy: signalById.get(issue.id)?.blockedBy ?? 0,
blocks: signalById.get(issue.id)?.blocks ?? 0,
isActionable: actionableNodeIds.has(issue.id),
isCycleNode: cycleNodeIdSet.has(issue.id),
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
blockerTooltipLines: blockerTooltipMap.get(issue.id) ?? [],
assignee: issue.assignee,
archetype: matchedArchetype,
isAssignMode: assignMode,
labels: issue.labels,
archetypes: archetypes,
selectedTaskId: selectedId,
onConversationOpen: onSelect,
onViewInSocial: onViewInSocial,
onAssignMode: onAssignMode,
onViewTelemetry: onViewTelemetry,
},
position: { x: 0, y: 0 },
sourcePosition,
targetPosition,
type: 'flowNode',
};
});
const visibleIds = new Set(baseNodes.map((node) => node.id));
const graphEdges: Edge[] = [];
for (const issue of beads) {
for (const dep of issue.dependencies) {
if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue;
if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue;
if (dep.type !== 'blocks') continue;
if (issue.id === dep.target) continue;
const edgeId = `${dep.target}:blocks:${issue.id}`;
const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false;
const sourceIssue = beads.find((i) => i.id === dep.target);
const isInProgressEdge = issue.status === 'in_progress' || sourceIssue?.status === 'in_progress';
graphEdges.push({
id: edgeId,
source: dep.target,
target: issue.id,
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
animated: linkedToSelection || isInProgressEdge,
label: 'BLOCKS',
labelStyle: {
fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.08em',
},
labelBgPadding: [6, 3],
labelBgBorderRadius: 999,
labelBgStyle: {
fill: 'rgba(2, 6, 23, 0.92)',
stroke: linkedToSelection ? 'rgba(125, 211, 252, 0.35)' : 'rgba(251, 191, 36, 0.25)',
strokeWidth: 1,
},
style: {
stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24',
strokeWidth: linkedToSelection ? 2.8 : 2.1,
opacity: linkedToSelection ? 1 : 0.78,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: linkedToSelection ? '#7dd3fc' : '#fbbf24',
width: 14,
height: 14,
},
});
}
}
return {
nodes: layoutDagre(baseNodes, graphEdges, layoutDirection, layoutDensity),
edges: graphEdges,
};
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode, onSelect, onViewInSocial, onAssignMode, onViewTelemetry, layoutDirection, layoutDensity]);
const nodeTypes: NodeTypes = useMemo(
() => ({
flowNode: GraphNodeCard as NodeTypes['flowNode'],
}),
[],
);
const defaultEdgeOptions = useMemo(
() => ({
type: 'smoothstep' as const,
zIndex: 40,
interactionWidth: 24,
}),
[],
);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 200 });
}, 50);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length, layoutDirection, layoutDensity]);
const handleFitToScreen = useCallback(() => {
fitView({ padding: 0.24, duration: 240 });
}, [fitView]);
return (
<div className={`relative h-full min-h-[24rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner ${className}`}>
<div className="workflow-graph-legend absolute left-3 top-3 z-10 flex flex-wrap items-center gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-3 py-2 backdrop-blur-sm">
<p className="text-[10px] text-text-muted/60">
<span className="font-bold uppercase tracking-[0.15em]">Legend</span>{' '}
{!hideClosed ? (
<>
<span className="text-emerald-400">Done</span>
{' \u2192 '}
</>
) : null}
<span className="text-amber-400">In Progress</span>
{' \u2192 '}
<span className="text-cyan-400">Ready</span>
{' \u2192 '}
<span className="text-rose-400">Blocked</span>
</p>
{blockerAnalysis ? (
<p className="text-[10px] text-text-muted/60">
Open blockers: {blockerAnalysis.openBlockerCount}
</p>
) : null}
</div>
<div className="absolute right-3 top-3 z-10 flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] p-1">
<button
type="button"
onClick={() => setLayoutDirection('LR')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
layoutDirection === 'LR'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Horizontal
</button>
<button
type="button"
onClick={() => setLayoutDirection('TB')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
layoutDirection === 'TB'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Vertical
</button>
</div>
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] p-1">
<button
type="button"
onClick={() => setLayoutDensity('compact')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
layoutDensity === 'compact'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Compact
</button>
<button
type="button"
onClick={() => setLayoutDensity('normal')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
layoutDensity === 'normal'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Normal
</button>
</div>
<button
type="button"
onClick={handleFitToScreen}
className="inline-flex items-center gap-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-3 py-1.5 text-xs font-semibold text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
aria-label="Fit graph to screen"
title="Fit graph to screen"
>
<Maximize2 className="h-3.5 w-3.5" />
Fit
</button>
</div>
<ReactFlow
className="workflow-graph-flow"
defaultEdgeOptions={defaultEdgeOptions}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={1.5}
// translateExtent removed for unlimited panning
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
onlyRenderVisibleElements
onNodeClick={handleNodeClick}
>
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
</ReactFlow>
</div>
);
}
export function WorkflowGraph(props: WorkflowGraphProps) {
return (
<ReactFlowProvider>
<WorkflowGraphInner {...props} />
</ReactFlowProvider>
);
}
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import { Maximize2 } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
import { buildWorkflowEdges } from '../../lib/epic-graph';
import { useGraphAnalysis } from '../../hooks/use-graph-analysis';
import { identifyTransitiveEdges } from '../../lib/graph-view';
import { GraphNodeCard, type GraphNodeData } from '../graph/graph-node-card';
import { OffsetEdge } from '../graph/offset-edge';
export interface WorkflowGraphProps {
beads: BeadIssue[];
selectedId?: string;
onSelect?: (id: string) => void;
onViewInSocial?: (id: string) => void;
onAssignMode?: (id: string) => void;
onViewTelemetry?: (id: string) => void;
className?: string;
hideClosed?: boolean;
archetypes?: AgentArchetype[];
assignMode?: boolean;
swarmId?: string;
}
const NODE_WIDTH = 320;
const NODE_HEIGHT = 150;
type LayoutDirection = 'LR' | 'TB';
type LayoutDensity = 'normal' | 'compact';
function layoutDagre(
nodes: Node<GraphNodeData>[],
edges: Edge[],
direction: LayoutDirection,
density: LayoutDensity,
): Node<GraphNodeData>[] {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
rankdir: direction,
ranksep: density === 'compact' ? 70 : 120,
nodesep: density === 'compact' ? 35 : 70,
});
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
};
});
}
function WorkflowGraphInner({
beads,
selectedId,
onSelect,
onViewInSocial,
onAssignMode,
onViewTelemetry,
className = '',
hideClosed = false,
archetypes = [],
assignMode = false,
}: WorkflowGraphProps) {
const { fitView } = useReactFlow();
const [layoutDirection, setLayoutDirection] = useState<LayoutDirection>('LR');
const [layoutDensity, setLayoutDensity] = useState<LayoutDensity>('normal');
const [showHierarchy, setShowHierarchy] = useState(true);
// Use the extracted hook for all graph analysis
const {
graphModel,
signalById,
cycleNodeIdSet,
actionableNodeIds,
blockerTooltipMap,
blockerAnalysis,
chainNodeIds,
} = useGraphAnalysis(beads, 'workflow', selectedId);
const transitiveEdges = useMemo(() => identifyTransitiveEdges(graphModel), [graphModel]);
const flowModel = useMemo(() => {
const visibleBeads = beads.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'));
if (visibleBeads.length === 0) {
return { nodes: [] as Node<GraphNodeData>[], edges: [] as Edge[] };
}
const sourcePosition = layoutDirection === 'TB' ? Position.Bottom : Position.Right;
const targetPosition = layoutDirection === 'TB' ? Position.Top : Position.Left;
const baseNodes: Node<GraphNodeData>[] = visibleBeads.map((issue) => {
let matchedArchetype: AgentArchetype | undefined;
if (archetypes && issue.assignee) {
const assigneeStr = issue.assignee.toLowerCase();
matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
}
return {
id: issue.id,
data: {
title: issue.title,
kind: 'issue' as const,
status: issue.status,
priority: issue.priority,
blockedBy: signalById.get(issue.id)?.blockedBy ?? 0,
blocks: signalById.get(issue.id)?.blocks ?? 0,
isActionable: actionableNodeIds.has(issue.id),
isCycleNode: cycleNodeIdSet.has(issue.id),
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
blockerTooltipLines: blockerTooltipMap.get(issue.id) ?? [],
assignee: issue.assignee,
archetype: matchedArchetype,
isAssignMode: assignMode,
labels: issue.labels,
archetypes: archetypes,
selectedTaskId: selectedId,
onConversationOpen: onSelect,
onViewInSocial: onViewInSocial,
onAssignMode: onAssignMode,
onViewTelemetry: onViewTelemetry,
},
position: { x: 0, y: 0 },
sourcePosition,
targetPosition,
type: 'flowNode',
};
});
const visibleIds = new Set(baseNodes.map((node) => node.id));
const edgeDescriptors = buildWorkflowEdges({
issues: beads,
visibleIds,
selectedId: selectedId ?? null,
includeHierarchy: showHierarchy,
});
const graphEdges: Edge[] = edgeDescriptors.map((edge) => {
const isSubtask = edge.kind === 'subtask';
const label = isSubtask ? 'SUBTASK' : 'BLOCKS';
const isTransitive = transitiveEdges.has(`${edge.source}:blocks:${edge.target}`);
let stroke = '#64748b'; // default slate for subtasks / generic
let strokeBg = 'rgba(100, 116, 139, 0.3)';
let dashArray: string | undefined = undefined;
let opacity = 0.78;
const isFocusedPath = edge.isUpstreamOfFocus || edge.isDownstreamOfFocus || edge.isDirectlyFocused;
const isAnimated = isFocusedPath || edge.sourceStatus === 'in_progress';
if (isSubtask) {
stroke = isFocusedPath ? '#94a3b8' : '#64748b';
strokeBg = isFocusedPath ? 'rgba(148, 163, 184, 0.4)' : 'rgba(100, 116, 139, 0.3)';
dashArray = '6 4';
opacity = isFocusedPath ? 1 : (edge.isUnrelated ? 0.15 : 0.58);
} else {
// Evaluate Base Status
if (edge.sourceStatus === 'in_progress') {
stroke = '#fbbf24'; // Bright Amber
strokeBg = 'rgba(251, 191, 36, 0.25)';
} else if (edge.sourceStatus === 'blocked') {
stroke = '#f43f5e'; // Rose/Red for deep block
strokeBg = 'rgba(244, 63, 94, 0.25)';
} else {
stroke = '#3b82f6'; // Blue
strokeBg = 'rgba(59, 130, 246, 0.25)';
}
// Overrides for Selection
if (selectedId) {
if (edge.isUnrelated) {
stroke = '#1e293b'; // Super dim
strokeBg = 'transparent';
opacity = 0.15;
} else if (edge.isUpstreamOfFocus || (edge.isDirectlyFocused && edge.target === selectedId)) {
stroke = '#f59e0b'; // Amber for upstream blockers
strokeBg = 'rgba(245, 158, 11, 0.35)';
opacity = 1;
} else if (edge.isDownstreamOfFocus || (edge.isDirectlyFocused && edge.source === selectedId)) {
stroke = '#0ea5e9'; // Cyan for downstream impact
strokeBg = 'rgba(14, 165, 233, 0.35)';
opacity = 1;
}
}
// Transitive styling
if (isTransitive) {
dashArray = '4 4';
if (!selectedId || edge.isUnrelated) {
stroke = '#334155';
strokeBg = 'rgba(51, 65, 85, 0.3)';
opacity = 0.4;
} else {
opacity = 0.6; // Keep the focused color but make it dashed & slightly transparent
}
}
}
return {
id: edge.id,
source: edge.source,
target: edge.target,
className: isFocusedPath ? 'workflow-edge-selected' : 'workflow-edge-muted',
animated: isAnimated,
label,
labelStyle: {
fill: isFocusedPath ? '#e2e8f0' : '#cbd5e1',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.08em',
},
labelBgPadding: [6, 3],
labelBgBorderRadius: 999,
labelBgStyle: {
fill: 'rgba(2, 6, 23, 0.92)',
stroke: strokeBg,
strokeWidth: 1,
},
style: {
stroke,
strokeWidth: isFocusedPath ? 2.8 : 2.1,
opacity,
strokeDasharray: dashArray,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: stroke,
width: 14,
height: 14,
},
};
});
// --- Apply Offsets to Edge Data ---
const edgeGroups = new Map<string, Edge[]>();
for (const edge of graphEdges) {
const key = [edge.source, edge.target].sort().join('-');
if (!edgeGroups.has(key)) edgeGroups.set(key, []);
edgeGroups.get(key)!.push(edge);
}
for (const [unused_, groupEdges] of edgeGroups) {
if (groupEdges.length <= 1) continue;
// In Vertical layout, we might want X offset, in Horizontal Y offset.
// OffsetEdge component already handles adjusting the correct axis based on sourcePosition.
const step = 8;
const totalSpread = (groupEdges.length - 1) * step;
let currentOffset = -(totalSpread / 2);
for (const edge of groupEdges) {
edge.data = { ...edge.data, offset: currentOffset };
currentOffset += step;
}
}
return {
nodes: layoutDagre(baseNodes, graphEdges, layoutDirection, layoutDensity),
edges: graphEdges,
};
}, [transitiveEdges, beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode, onSelect, onViewInSocial, onAssignMode, onViewTelemetry, layoutDirection, layoutDensity, showHierarchy]);
const nodeTypes: NodeTypes = useMemo(
() => ({
flowNode: GraphNodeCard as NodeTypes['flowNode'],
}),
[],
);
const edgeTypes = useMemo(
() => ({
offset: OffsetEdge,
}),
[]
);
const defaultEdgeOptions = useMemo(
() => ({
type: 'offset' as const,
zIndex: 40,
interactionWidth: 24,
}),
[],
);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 200 });
}, 50);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length, layoutDirection, layoutDensity]);
const handleFitToScreen = useCallback(() => {
fitView({ padding: 0.24, duration: 240 });
}, [fitView]);
return (
<div className={`relative h-full min-h-[24rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner ${className}`}>
<div className="workflow-graph-legend absolute left-3 top-3 z-10 flex flex-wrap items-center gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-3 py-2 backdrop-blur-sm">
<p className="text-[10px] text-text-muted/60">
<span className="font-bold uppercase tracking-[0.15em]">Legend</span>{' '}
{!hideClosed ? (
<>
<span className="text-emerald-400">Done</span>
{' \u2192 '}
</>
) : null}
<span className="text-amber-400">In Progress</span>
{' \u2192 '}
<span className="text-cyan-400">Ready</span>
{' \u2192 '}
<span className="text-rose-400">Blocked</span>
</p>
{blockerAnalysis ? (
<p className="text-[10px] text-text-muted/60">
Open blockers: {blockerAnalysis.openBlockerCount}
</p>
) : null}
</div>
<div className="absolute right-3 top-3 z-10 flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] p-1">
<button
type="button"
onClick={() => setShowHierarchy((current) => !current)}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${showHierarchy
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
title="Show parent/subtask links"
>
Hierarchy
</button>
<button
type="button"
onClick={() => setLayoutDirection('LR')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${layoutDirection === 'LR'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Horizontal
</button>
<button
type="button"
onClick={() => setLayoutDirection('TB')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${layoutDirection === 'TB'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Vertical
</button>
</div>
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] p-1">
<button
type="button"
onClick={() => setLayoutDensity('compact')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${layoutDensity === 'compact'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Compact
</button>
<button
type="button"
onClick={() => setLayoutDensity('normal')}
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${layoutDensity === 'normal'
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Normal
</button>
</div>
<button
type="button"
onClick={handleFitToScreen}
className="inline-flex items-center gap-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-3 py-1.5 text-xs font-semibold text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
aria-label="Fit graph to screen"
title="Fit graph to screen"
>
<Maximize2 className="h-3.5 w-3.5" />
Fit
</button>
</div>
<ReactFlow
className="workflow-graph-flow"
defaultEdgeOptions={defaultEdgeOptions}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={1.5}
// translateExtent removed for unlimited panning
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
onlyRenderVisibleElements
onNodeClick={handleNodeClick}
>
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
</ReactFlow>
</div>
);
}
export function WorkflowGraph(props: WorkflowGraphProps) {
return (
<ReactFlowProvider>
<WorkflowGraphInner {...props} />
</ReactFlowProvider>
);
}

View file

@ -1,273 +1,273 @@
import type { KeyboardEvent, MouseEventHandler } from 'react';
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
import { AgentAvatar } from '../shared/agent-avatar';
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
import type { AgentArchetype } from '../../lib/types-swarm';
interface SocialCardProps {
data: SocialCardData;
className?: string;
selected?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
onJumpToGraph?: (id: string) => void;
onJumpToActivity?: (id: string) => void;
onOpenThread?: () => void;
description?: string;
updatedLabel?: string;
dependencyCount?: number;
commentCount?: number;
unreadCount?: number;
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
archetypes?: AgentArchetype[];
swarmId?: string;
onLaunchSwarm?: () => void;
}
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
if (!onClick) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
}
function statusVisual(status: SocialCardData['status']) {
if (status === 'blocked') {
return {
border: 'var(--accent-danger)',
badgeBg: 'var(--status-blocked)',
badgeText: '#ffd5df',
chipText: 'Blocked',
};
}
if (status === 'in_progress') {
return {
border: 'var(--accent-warning)',
badgeBg: 'var(--status-in-progress)',
badgeText: '#ffe5c7',
chipText: 'Active',
};
}
if (status === 'ready') {
return {
border: 'var(--accent-success)',
badgeBg: 'var(--status-ready)',
badgeText: '#d6ffe7',
chipText: 'Ready',
};
}
return {
border: 'var(--border-default)',
badgeBg: 'var(--status-closed)',
badgeText: 'var(--text-tertiary)',
chipText: 'Closed',
};
}
function dependencyPanel(
title: string,
color: string,
details: Array<{ id: string; title: string; epic?: string }>,
) {
if (details.length === 0) return null;
return (
<div className="rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-2">
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
{title}
</p>
<div className="space-y-1.5">
{details.slice(0, 1).map((item) => (
<div
key={`${title}-${item.id}`}
className="rounded border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-2 py-1.5"
>
<div className="mb-0.5 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-info)]" />
<span className="font-mono text-[10px] text-[var(--text-tertiary)]">{item.id}</span>
</div>
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
{item.epic ? (
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]"> {item.epic}</p>
) : null}
</div>
))}
</div>
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
</div>
);
}
export function SocialCard({
data,
className,
selected = false,
onClick,
onJumpToGraph,
onJumpToActivity,
onOpenThread,
description,
updatedLabel = 'just now',
dependencyCount,
commentCount,
unreadCount = 0,
blockedByDetails = [],
unblocksDetails = [],
archetypes = [],
swarmId,
onLaunchSwarm,
}: SocialCardProps) {
const status = statusVisual(data.status);
const { selectedArchetype, setSelectedArchetype, isAssigning, assignSuccess, handleAssign } = useArchetypePicker();
const showAssign = (data.status === 'blocked' || data.agents.length === 0) && archetypes.length > 0;
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
return (
<div
onClick={onClick}
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
role="button"
tabIndex={0}
aria-label={`Open ${data.title}`}
className={cn(
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
isSwarmHighlighted && 'ring-2 ring-blue-500',
className,
)}
style={{
background: 'var(--surface-quaternary)',
borderColor: selected ? status.border : 'var(--border-default)',
boxShadow: selected
? `0 0 0 2px ${status.border}, 0 20px 40px -20px rgba(0,0,0,0.6)`
: '0 4px 12px -6px rgba(0,0,0,0.4)',
}}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
{status.chipText}
</Badge>
<span className="font-mono text-[11px] text-[var(--accent-info)]">{data.priority}</span>
<span className="truncate font-mono text-[11px] text-[var(--text-tertiary)]">{data.id}</span>
{unreadCount > 0 ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-danger)] px-1 text-[10px] font-semibold text-[var(--text-inverse)]">
{unreadCount}
</span>
) : null}
</div>
</div>
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--text-primary)]">{data.title}</h3>
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--text-tertiary)]">
{description || 'No summary provided yet.'}
</p>
<div className="mt-2 flex flex-col gap-2">
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
</div>
<div className="mt-2 flex items-center gap-2">
{data.agents.slice(0, 3).map((agent) => (
<AgentAvatar
key={`${data.id}-${agent.name}`}
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
))}
{data.agents.length === 0 ? <span className="text-xs text-[var(--text-tertiary)]">No crew</span> : null}
</div>
{showAssign && (
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
<select
value={selectedArchetype ?? ''}
onChange={(e) => setSelectedArchetype(e.target.value || null)}
className="min-w-0 flex-1 text-xs border border-[var(--border-subtle)] rounded-md px-2 py-1.5 bg-[var(--surface-input)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-info)]"
>
<option value="" disabled>Select agent role...</option>
{archetypes.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<button
onClick={async (e) => {
e.stopPropagation();
await handleAssign(data.id);
}}
disabled={!selectedArchetype || isAssigning || assignSuccess}
className={`flex-shrink-0 px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors disabled:opacity-50 flex items-center gap-1 ${assignSuccess ? 'bg-[var(--accent-success)] text-white' : 'bg-[var(--accent-info)] text-white hover:bg-[var(--accent-info)]/90'}`}
>
<UserPlus className="w-3 h-3" />
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
</button>
</div>
)}
<div className="mt-auto border-t border-[var(--border-subtle)] pt-1.5">
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
<span className="font-mono text-[11px] text-[var(--accent-success)]">stage active</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View dependency graph"
title="View dependency graph"
>
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-success)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View details"
title="View details"
>
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{onLaunchSwarm ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onLaunchSwarm();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-emerald-500/20 bg-emerald-500/10 text-emerald-400 transition-colors hover:bg-emerald-500/20"
aria-label="Launch Swarm"
title="Launch Swarm"
>
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
</div>
</div>
</div>
</div>
);
}
import type { KeyboardEvent, MouseEventHandler } from 'react';
import { Clock3, GitBranch, Link2, MessageCircle, MessageSquare, Rocket, UserPlus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
import { AgentAvatar } from '../shared/agent-avatar';
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
import type { AgentArchetype } from '../../lib/types-swarm';
interface SocialCardProps {
data: SocialCardData;
className?: string;
selected?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
onJumpToGraph?: (id: string) => void;
onJumpToActivity?: (id: string) => void;
onOpenThread?: () => void;
description?: string;
updatedLabel?: string;
dependencyCount?: number;
commentCount?: number;
unreadCount?: number;
blockedByDetails?: Array<{ id: string; title: string; epic?: string }>;
unblocksDetails?: Array<{ id: string; title: string; epic?: string }>;
archetypes?: AgentArchetype[];
swarmId?: string;
onLaunchSwarm?: () => void;
}
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>, onClick?: MouseEventHandler<HTMLDivElement>) {
if (!onClick) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick(event as unknown as Parameters<MouseEventHandler<HTMLDivElement>>[0]);
}
function statusVisual(status: SocialCardData['status']) {
if (status === 'blocked') {
return {
border: 'var(--accent-danger)',
badgeBg: 'var(--status-blocked)',
badgeText: '#ffd5df',
chipText: 'Blocked',
};
}
if (status === 'in_progress') {
return {
border: 'var(--accent-warning)',
badgeBg: 'var(--status-in-progress)',
badgeText: '#ffe5c7',
chipText: 'Active',
};
}
if (status === 'ready') {
return {
border: 'var(--accent-success)',
badgeBg: 'var(--status-ready)',
badgeText: '#d6ffe7',
chipText: 'Ready',
};
}
return {
border: 'var(--border-default)',
badgeBg: 'var(--status-closed)',
badgeText: 'var(--text-tertiary)',
chipText: 'Closed',
};
}
function dependencyPanel(
title: string,
color: string,
details: Array<{ id: string; title: string; epic?: string }>,
) {
if (details.length === 0) return null;
return (
<div className="rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-2.5 py-2">
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.12em]" style={{ color }}>
{title}
</p>
<div className="space-y-1.5">
{details.slice(0, 1).map((item) => (
<div
key={`${title}-${item.id}`}
className="rounded border border-[var(--border-subtle)] bg-[var(--surface-quaternary)] px-2 py-1.5"
>
<div className="mb-0.5 flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-info)]" />
<span className="font-mono text-[10px] text-[var(--text-tertiary)]">{item.id}</span>
</div>
<p className="line-clamp-1 text-xs text-[var(--text-primary)]">{item.title}</p>
{item.epic ? (
<p className="line-clamp-1 text-[10px] text-[var(--accent-info)]"> {item.epic}</p>
) : null}
</div>
))}
</div>
{details.length > 1 ? <p className="mt-1 text-[11px] text-[var(--text-tertiary)]">+{details.length - 1} more</p> : null}
</div>
);
}
export function SocialCard({
data,
className,
selected = false,
onClick,
onJumpToGraph,
onJumpToActivity,
onOpenThread,
description,
updatedLabel = 'just now',
dependencyCount,
commentCount,
unreadCount = 0,
blockedByDetails = [],
unblocksDetails = [],
archetypes = [],
swarmId,
onLaunchSwarm,
}: SocialCardProps) {
const status = statusVisual(data.status);
const { selectedArchetype, setSelectedArchetype, isAssigning, assignSuccess, handleAssign } = useArchetypePicker();
const showAssign = (data.status === 'blocked' || data.agents.length === 0) && archetypes.length > 0;
const isSwarmHighlighted = swarmId && data.id.includes(swarmId);
return (
<div
onClick={onClick}
onKeyDown={(event) => handleCardKeyDown(event, onClick)}
role="button"
tabIndex={0}
aria-label={`Open ${data.title}`}
className={cn(
'group relative flex min-h-[290px] cursor-pointer flex-col rounded-[14px] border px-3.5 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]',
isSwarmHighlighted && 'ring-2 ring-blue-500',
className,
)}
style={{
background: 'var(--surface-quaternary)',
borderColor: selected ? status.border : 'var(--border-default)',
boxShadow: selected
? `0 0 0 2px ${status.border}, 0 20px 40px -20px rgba(0,0,0,0.6)`
: '0 4px 12px -6px rgba(0,0,0,0.4)',
}}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Badge className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em]" style={{ backgroundColor: status.badgeBg, color: status.badgeText }}>
{status.chipText}
</Badge>
<span className="font-mono text-[11px] text-[var(--accent-info)]">{data.priority}</span>
<span className="truncate font-mono text-[11px] text-[var(--text-tertiary)]">{data.id}</span>
{unreadCount > 0 ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent-danger)] px-1 text-[10px] font-semibold text-[var(--text-inverse)]">
{unreadCount}
</span>
) : null}
</div>
</div>
<h3 className="line-clamp-2 text-[27px] font-semibold leading-[1.13] tracking-[-0.01em] text-[var(--text-primary)]">{data.title}</h3>
<p className="mt-1.5 line-clamp-3 min-h-[56px] text-[13px] leading-relaxed text-[var(--text-tertiary)]">
{description || 'No summary provided yet.'}
</p>
<div className="mt-2 flex flex-col gap-2">
{dependencyPanel('Blocked By', 'var(--accent-danger)', blockedByDetails)}
{dependencyPanel('Unblocks', 'var(--accent-success)', unblocksDetails)}
</div>
<div className="mt-2 flex items-center gap-2">
{data.agents.slice(0, 3).map((agent) => (
<AgentAvatar
key={`${data.id}-${agent.name}`}
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
))}
{data.agents.length === 0 ? <span className="text-xs text-[var(--text-tertiary)]">No crew</span> : null}
</div>
{showAssign && (
<div className="mt-2 flex gap-2 items-center overflow-hidden" onClick={(e) => e.stopPropagation()}>
<select
value={selectedArchetype ?? ''}
onChange={(e) => setSelectedArchetype(e.target.value || null)}
className="min-w-0 flex-1 text-xs border border-[var(--border-subtle)] rounded-md px-2 py-1.5 bg-[var(--surface-input)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-info)]"
>
<option value="" disabled>Select agent role...</option>
{archetypes.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<button
onClick={async (e) => {
e.stopPropagation();
await handleAssign(data.id);
}}
disabled={!selectedArchetype || isAssigning || assignSuccess}
className={`flex-shrink-0 px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors disabled:opacity-50 flex items-center gap-1 ${assignSuccess ? 'bg-[var(--accent-success)] text-white' : 'bg-[var(--accent-info)] text-white hover:bg-[var(--accent-info)]/90'}`}
>
<UserPlus className="w-3 h-3" />
{isAssigning ? '...' : assignSuccess ? '✓' : 'Assign'}
</button>
</div>
)}
<div className="mt-auto border-t border-[var(--border-subtle)] pt-1.5">
<div className="mb-1.5 flex items-center justify-between text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Clock3 className="h-3.5 w-3.5" aria-hidden="true" />{updatedLabel}</span>
<span className="font-mono text-[11px] text-[var(--accent-success)]">stage active</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
<span className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</span>
<span className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" aria-hidden="true" />{commentCount ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToGraph?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-info)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View dependency graph"
title="View dependency graph"
>
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onJumpToActivity?.(data.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] text-[var(--accent-success)] transition-colors hover:bg-[var(--alpha-white-low)]"
aria-label="View details"
title="View details"
>
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{onLaunchSwarm ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onLaunchSwarm();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-emerald-500/20 bg-emerald-500/10 text-emerald-400 transition-colors hover:bg-emerald-500/20"
aria-label="Launch Swarm"
title="Launch Swarm"
>
<Rocket className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,285 +1,285 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { buildSocialCards } from '../../lib/social-cards';
import { SocialCard } from './social-card';
import { useArchetypes } from '../../hooks/use-archetypes';
interface SocialPageProps {
issues: BeadIssue[];
selectedId?: string;
onSelect: (id: string) => void;
projectScopeOptions?: ProjectScopeOption[];
blockedOnly?: boolean;
projectRoot: string;
swarmId?: string;
onRocketClick?: () => void;
}
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
const SECTION_LABEL: Record<SectionKey, string> = {
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
done: 'Done',
};
const SECTION_COLOR: Record<SectionKey, string> = {
ready: 'var(--ui-accent-ready)',
in_progress: 'var(--ui-accent-warning)',
blocked: 'var(--ui-accent-blocked)',
deferred: 'var(--ui-accent-info)',
done: 'var(--ui-text-muted)',
};
function bucketForStatus(status: string): SectionKey {
if (status === 'ready') return 'ready';
if (status === 'in_progress') return 'in_progress';
if (status === 'blocked') return 'blocked';
if (status === 'closed') return 'done';
return 'deferred';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function SocialPage({
issues,
selectedId,
onSelect,
projectScopeOptions = [],
blockedOnly = false,
projectRoot,
swarmId,
onRocketClick,
}: SocialPageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const cards = useMemo(() => buildSocialCards(issues), [issues]);
const { archetypes } = useArchetypes(projectRoot);
const navigateWithParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
router.push(query ? `/?${query}` : '/', { scroll: false });
};
const issueById = useMemo(() => {
const map = new Map<string, BeadIssue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const epicTitleById = useMemo(() => {
const map = new Map<string, string>();
for (const issue of issues) {
if (issue.issue_type === 'epic') {
map.set(issue.id, issue.title);
}
}
return map;
}, [issues]);
const toDependencyDetails = (ids: string[]) =>
ids.map((id) => {
const depIssue = issueById.get(id);
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
return {
id,
title: depIssue?.title ?? id,
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
};
});
const orderedCards = useMemo(
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
[cards],
);
const visibleCards = useMemo(
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
[blockedOnly, orderedCards],
);
const grouped = useMemo(() => {
const map: Record<SectionKey, typeof visibleCards> = {
ready: [],
in_progress: [],
blocked: [],
deferred: [],
done: [],
};
for (const card of visibleCards) {
map[bucketForStatus(card.status)].push(card);
}
return map;
}, [visibleCards]);
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: false,
done: false,
});
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: true,
done: true,
});
return (
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{projectScopeOptions.length} scopes
</span>
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{visibleCards.length} tasks
</span>
</div>
</div>
<section className="space-y-6">
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
const cardsForSection = grouped[key];
return (
<div key={key}>
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
{SECTION_LABEL[key]}
</p>
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
{cardsForSection.length}
</span>
{(key === 'deferred' || key === 'done') ? (
<button
type="button"
onClick={() =>
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
>
{collapsedSections[key] ? 'Expand' : 'Minimize'}
</button>
) : null}
</div>
{collapsedSections[key] ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
{cardsForSection.length === 0
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
: `${cardsForSection.length} tasks hidden.`}
</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
return (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) =>
navigateWithParams({
view: 'graph',
graphTab: 'flow',
task: id,
swarm: null,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onJumpToActivity={(id) =>
navigateWithParams({
task: id,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onOpenThread={() => onSelect(card.id)}
description={issue?.description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={dependencyCount}
commentCount={commentCount}
unreadCount={unreadCount}
blockedByDetails={toDependencyDetails(card.unblocks)}
unblocksDetails={toDependencyDetails(card.blocks)}
archetypes={archetypes}
swarmId={swarmId}
onLaunchSwarm={onRocketClick}
/>
);
})}
{cardsForSection.length === 0 ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No tasks in this lane.
</p>
) : null}
</div>
)}
{!collapsedSections[key] && cardsForSection.length > 3 ? (
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() =>
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
>
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
</button>
</div>
) : null}
</div>
);
})}
</section>
{visibleCards.length === 0 ? (
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No blocked tasks right now.
</p>
) : null}
</div>
</div>
);
}
'use client';
import { useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { buildSocialCards } from '../../lib/social-cards';
import { SocialCard } from './social-card';
import { useArchetypes } from '../../hooks/use-archetypes';
interface SocialPageProps {
issues: BeadIssue[];
selectedId?: string;
onSelect: (id: string) => void;
projectScopeOptions?: ProjectScopeOption[];
blockedOnly?: boolean;
projectRoot: string;
swarmId?: string;
onRocketClick?: () => void;
}
type SectionKey = 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done';
const SECTION_LABEL: Record<SectionKey, string> = {
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
done: 'Done',
};
const SECTION_COLOR: Record<SectionKey, string> = {
ready: 'var(--ui-accent-ready)',
in_progress: 'var(--ui-accent-warning)',
blocked: 'var(--ui-accent-blocked)',
deferred: 'var(--ui-accent-info)',
done: 'var(--ui-text-muted)',
};
function bucketForStatus(status: string): SectionKey {
if (status === 'ready') return 'ready';
if (status === 'in_progress') return 'in_progress';
if (status === 'blocked') return 'blocked';
if (status === 'closed') return 'done';
return 'deferred';
}
function formatRelative(timestamp: string): string {
const then = new Date(timestamp);
const now = new Date();
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function SocialPage({
issues,
selectedId,
onSelect,
projectScopeOptions = [],
blockedOnly = false,
projectRoot,
swarmId,
onRocketClick,
}: SocialPageProps) {
const router = useRouter();
const searchParams = useSearchParams();
const cards = useMemo(() => buildSocialCards(issues), [issues]);
const { archetypes } = useArchetypes(projectRoot);
const navigateWithParams = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) next.delete(key);
else next.set(key, value);
}
const query = next.toString();
router.push(query ? `/?${query}` : '/', { scroll: false });
};
const issueById = useMemo(() => {
const map = new Map<string, BeadIssue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const epicTitleById = useMemo(() => {
const map = new Map<string, string>();
for (const issue of issues) {
if (issue.issue_type === 'epic') {
map.set(issue.id, issue.title);
}
}
return map;
}, [issues]);
const toDependencyDetails = (ids: string[]) =>
ids.map((id) => {
const depIssue = issueById.get(id);
const parentEpicId = depIssue?.dependencies.find((dep) => dep.type === 'parent')?.target;
return {
id,
title: depIssue?.title ?? id,
epic: parentEpicId ? epicTitleById.get(parentEpicId) : undefined,
};
});
const orderedCards = useMemo(
() => [...cards].sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()),
[cards],
);
const visibleCards = useMemo(
() => (blockedOnly ? orderedCards.filter((card) => card.status === 'blocked') : orderedCards),
[blockedOnly, orderedCards],
);
const grouped = useMemo(() => {
const map: Record<SectionKey, typeof visibleCards> = {
ready: [],
in_progress: [],
blocked: [],
deferred: [],
done: [],
};
for (const card of visibleCards) {
map[bucketForStatus(card.status)].push(card);
}
return map;
}, [visibleCards]);
const [expandedSections, setExpandedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: false,
done: false,
});
const [collapsedSections, setCollapsedSections] = useState<Record<SectionKey, boolean>>({
ready: false,
in_progress: false,
blocked: false,
deferred: true,
done: true,
});
return (
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] custom-scrollbar">
<div className="mx-auto w-full max-w-[1280px] px-4 pb-8 pt-4 xl:px-6">
<div className="mb-4 flex items-center justify-between gap-3 border-b border-[var(--ui-border-soft)] pb-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Social Stream</p>
<p className="text-sm text-[var(--ui-text-primary)]">Task Activity Command Feed</p>
</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{projectScopeOptions.length} scopes
</span>
<span className="rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[var(--ui-text-muted)]">
{visibleCards.length} tasks
</span>
</div>
</div>
<section className="space-y-6">
{(Object.keys(SECTION_LABEL) as SectionKey[]).map((key) => {
const cardsForSection = grouped[key];
return (
<div key={key}>
<div className="mb-3 flex items-center gap-2 border-b border-[var(--ui-border-soft)] pb-2">
<p className="font-mono text-xs uppercase tracking-[0.14em]" style={{ color: SECTION_COLOR[key] }}>
{SECTION_LABEL[key]}
</p>
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-1.5 text-[10px] text-[var(--ui-text-primary)]">
{cardsForSection.length}
</span>
{(key === 'deferred' || key === 'done') ? (
<button
type="button"
onClick={() =>
setCollapsedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="ml-auto rounded border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)]"
>
{collapsedSections[key] ? 'Expand' : 'Minimize'}
</button>
) : null}
</div>
{collapsedSections[key] ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
{cardsForSection.length === 0
? `No tasks in ${SECTION_LABEL[key].toLowerCase()}.`
: `${cardsForSection.length} tasks hidden.`}
</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{(expandedSections[key] ? cardsForSection : cardsForSection.slice(0, 3)).map((card) => {
const issue = issueById.get(card.id);
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
const dependencyCount = issue?.dependencies.length ?? card.blocks.length + card.unblocks.length;
return (
<SocialCard
key={card.id}
data={card}
selected={selectedId === card.id}
onClick={() => onSelect(card.id)}
onJumpToGraph={(id) =>
navigateWithParams({
view: 'graph',
graphTab: 'flow',
task: id,
swarm: null,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onJumpToActivity={(id) =>
navigateWithParams({
task: id,
right: 'open',
panel: 'open',
drawer: 'closed',
})
}
onOpenThread={() => onSelect(card.id)}
description={issue?.description ?? undefined}
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
dependencyCount={dependencyCount}
commentCount={commentCount}
unreadCount={unreadCount}
blockedByDetails={toDependencyDetails(card.unblocks)}
unblocksDetails={toDependencyDetails(card.blocks)}
archetypes={archetypes}
swarmId={swarmId}
onLaunchSwarm={onRocketClick}
/>
);
})}
{cardsForSection.length === 0 ? (
<p className="px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No tasks in this lane.
</p>
) : null}
</div>
)}
{!collapsedSections[key] && cardsForSection.length > 3 ? (
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() =>
setExpandedSections((current) => ({ ...current, [key]: !current[key] }))
}
className="rounded-md border border-[var(--ui-border-soft)] bg-[var(--ui-bg-panel)] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-[0.1em] text-[var(--ui-text-muted)] transition-colors hover:text-[var(--ui-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]"
>
{expandedSections[key] ? 'Show Less' : `Show ${cardsForSection.length - 3} More`}
</button>
</div>
) : null}
</div>
);
})}
</section>
{visibleCards.length === 0 ? (
<p className="mt-5 px-0.5 py-1 text-sm text-[var(--ui-text-muted)]">
No blocked tasks right now.
</p>
) : null}
</div>
</div>
);
}

View file

@ -1,31 +1,31 @@
"use client";
import { Loader2, CheckCircle2 } from "lucide-react";
export type Phase = 'planning' | 'deployment' | 'execution' | 'debrief';
export function ConvoyStepper({ activePhase }: { activePhase: Phase }) {
const phases: Phase[] = ['planning', 'deployment', 'execution', 'debrief'];
return (
<div className="flex flex-wrap items-center gap-4 bg-muted/50 p-4 rounded-lg my-4">
{phases.map((p, i) => {
const isActive = activePhase === p;
const isPast = phases.indexOf(activePhase) > i;
return (
<div
key={p}
className={`flex items-center gap-2 ${isActive ? 'text-primary' : isPast ? 'text-muted-foreground' : 'text-muted-foreground/50'
}`}
>
{isActive && <Loader2 className="w-4 h-4 animate-spin" />}
{isPast && <CheckCircle2 className="w-4 h-4" />}
{!isActive && !isPast && <div className="w-4 h-4 rounded-full border border-current" />}
<span className="font-mono text-sm uppercase">{p}</span>
</div>
);
})}
</div>
);
}
"use client";
import { Loader2, CheckCircle2 } from "lucide-react";
export type Phase = 'planning' | 'deployment' | 'execution' | 'debrief';
export function ConvoyStepper({ activePhase }: { activePhase: Phase }) {
const phases: Phase[] = ['planning', 'deployment', 'execution', 'debrief'];
return (
<div className="flex flex-wrap items-center gap-4 bg-muted/50 p-4 rounded-lg my-4">
{phases.map((p, i) => {
const isActive = activePhase === p;
const isPast = phases.indexOf(activePhase) > i;
return (
<div
key={p}
className={`flex items-center gap-2 ${isActive ? 'text-primary' : isPast ? 'text-muted-foreground' : 'text-muted-foreground/50'
}`}
>
{isActive && <Loader2 className="w-4 h-4 animate-spin" />}
{isPast && <CheckCircle2 className="w-4 h-4" />}
{!isActive && !isPast && <div className="w-4 h-4 rounded-full border border-current" />}
<span className="font-mono text-sm uppercase">{p}</span>
</div>
);
})}
</div>
);
}

View file

@ -1,210 +1,238 @@
import React, { useEffect, useMemo } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
Handle,
type Edge,
type Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
// Custom Node for the Agent DAG
interface AgentNodeData extends Record<string, unknown> {
title: string;
status: string;
assignee: string | null;
archetype?: AgentArchetype;
isSelected?: boolean;
}
function AgentNodeCard({ data }: { data: AgentNodeData }) {
const isDone = data.status === 'closed';
const isInProgress = data.status === 'in_progress';
const isBlocked = data.status === 'blocked';
const statusColor = isDone ? 'text-emerald-400' : isBlocked ? 'text-rose-400' : isInProgress ? 'text-amber-400' : 'text-slate-400';
let borderColor = isDone ? 'border-emerald-500/30' : isBlocked ? 'border-rose-500/30' : isInProgress ? 'border-amber-500/30' : 'border-slate-500/30';
let containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-xl transition-all duration-500 ${borderColor}`;
if (isInProgress) {
containerClasses += ' shadow-[0_0_20px_rgba(251,191,36,0.15)] ring-1 ring-amber-500/30';
}
if (data.isSelected) {
containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-[0_0_25px_rgba(56,189,248,0.2)] transition-all duration-300 border-[var(--ui-accent-info)] ring-2 ring-[var(--ui-accent-info)]/50`;
}
const bgStr = data.archetype ? `${data.archetype.color}15` : '#ffffff05';
const colorStr = data.archetype ? data.archetype.color : '#888';
return (
<div className={containerClasses}>
<div className="flex items-start gap-3">
<div
className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border relative ${isInProgress ? 'animate-pulse duration-1000' : ''}`}
style={{ backgroundColor: bgStr, color: colorStr, borderColor: `${colorStr}40` }}
>
{data.assignee ? data.assignee.charAt(0).toUpperCase() : '?'}
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor} ${isInProgress ? 'animate-ping' : ''}`} style={{ animationDuration: '2s' }} />
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-0.5 truncate flex items-center justify-between">
<span>{data.assignee || 'Unassigned'}</span>
{isInProgress && <span className="text-amber-500 animate-pulse text-[8px] tracking-widest">WORKING...</span>}
</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-tight line-clamp-2">
{data.title}
</div>
{data.archetype && (
<div className="text-[9px] text-[var(--ui-text-muted)] mt-1 truncate">
{data.archetype.name}
</div>
)}
</div>
</div>
{/* React Flow handles */}
<Handle type="target" position={Position.Left} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !left-[-8px] opacity-0" />
<Handle type="source" position={Position.Right} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !right-[-8px] opacity-0" />
</div>
);
}
const nodeTypes = {
agentNode: AgentNodeCard,
};
const layoutDagre = (nodes: Node<AgentNodeData>[], edges: Edge[]): Node<AgentNodeData>[] => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 60 });
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: 260, height: 110 });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
const newNode = { ...node };
if (nodeWithPosition) {
newNode.targetPosition = Position.Left;
newNode.sourcePosition = Position.Right;
newNode.position = {
x: nodeWithPosition.x - 260 / 2,
y: nodeWithPosition.y - 110 / 2,
};
}
return newNode;
});
};
function SpecializedAgentDagInner({ beads, archetypes, selectedId, onSelect }: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
const { fitView } = useReactFlow();
const handleNodeClick = React.useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
const flowModel = useMemo(() => {
// Find visible beads (hide tombstone)
const visibleBeads = beads.filter(b => b.status !== 'tombstone');
const baseNodes: Node<AgentNodeData>[] = visibleBeads.map((issue) => {
const assigneeStr = issue.assignee?.toLowerCase() || '';
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
return {
id: issue.id,
type: 'agentNode',
data: {
title: issue.title,
status: issue.status,
assignee: issue.assignee,
archetype: matchedArchetype,
isSelected: issue.id === selectedId
},
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
const graphEdges: Edge[] = [];
const beadIds = new Set(visibleBeads.map(b => b.id));
visibleBeads.forEach(issue => {
issue.dependencies.forEach(dep => {
if (dep.type === 'blocks' && beadIds.has(dep.target)) {
// issue depends on dep.target (issue is blocked by dep.target)
// Edge should flow from blocker to blocked
graphEdges.push({
id: `e-${dep.target}-${issue.id}`,
source: dep.target,
target: issue.id,
type: 'smoothstep',
animated: issue.status === 'in_progress' || issue.status === 'closed',
style: { stroke: '#475569', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#475569' }
});
}
});
});
console.log('SpecializedAgentDag generated nodes:', baseNodes.length, 'edges:', graphEdges.length);
return {
nodes: layoutDagre(baseNodes, graphEdges),
edges: graphEdges,
};
}, [beads, archetypes, selectedId]);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 300 });
}, 100);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length]);
return (
<ReactFlow
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
onNodeClick={handleNodeClick}
fitView
>
<Background gap={24} size={1} color="rgba(255,255,255,0.02)" />
</ReactFlow>
);
}
export function SpecializedAgentDag(props: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
return (
<ReactFlowProvider>
<SpecializedAgentDagInner {...props} />
</ReactFlowProvider>
);
}
import React, { useEffect, useMemo } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
Handle,
type Edge,
type Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
import { buildGraphModel } from '../../lib/graph';
import { identifyTransitiveEdges } from '../../lib/graph-view';
// Custom Node for the Agent DAG
interface AgentNodeData extends Record<string, unknown> {
title: string;
status: string;
assignee: string | null;
archetype?: AgentArchetype;
isSelected?: boolean;
}
function AgentNodeCard({ data }: { data: AgentNodeData }) {
const isDone = data.status === 'closed';
const isInProgress = data.status === 'in_progress';
const isBlocked = data.status === 'blocked';
const statusColor = isDone ? 'text-emerald-400' : isBlocked ? 'text-rose-400' : isInProgress ? 'text-amber-400' : 'text-slate-400';
let borderColor = isDone ? 'border-emerald-500/30' : isBlocked ? 'border-rose-500/30' : isInProgress ? 'border-amber-500/30' : 'border-slate-500/30';
let containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-xl transition-all duration-500 ${borderColor}`;
if (isInProgress) {
containerClasses += ' shadow-[0_0_20px_rgba(251,191,36,0.15)] ring-1 ring-amber-500/30';
}
if (data.isSelected) {
containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-[0_0_25px_rgba(56,189,248,0.2)] transition-all duration-300 border-[var(--ui-accent-info)] ring-2 ring-[var(--ui-accent-info)]/50`;
}
const bgStr = data.archetype ? `${data.archetype.color}15` : '#ffffff05';
const colorStr = data.archetype ? data.archetype.color : '#888';
return (
<div className={containerClasses}>
<div className="flex items-start gap-3">
<div
className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border relative ${isInProgress ? 'animate-pulse duration-1000' : ''}`}
style={{ backgroundColor: bgStr, color: colorStr, borderColor: `${colorStr}40` }}
>
{data.assignee ? data.assignee.charAt(0).toUpperCase() : '?'}
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor} ${isInProgress ? 'animate-ping' : ''}`} style={{ animationDuration: '2s' }} />
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-0.5 truncate flex items-center justify-between">
<span>{data.assignee || 'Unassigned'}</span>
{isInProgress && <span className="text-amber-500 animate-pulse text-[8px] tracking-widest">WORKING...</span>}
</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-tight line-clamp-2">
{data.title}
</div>
{data.archetype && (
<div className="text-[9px] text-[var(--ui-text-muted)] mt-1 truncate">
{data.archetype.name}
</div>
)}
</div>
</div>
{/* React Flow handles */}
<Handle type="target" position={Position.Left} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !left-[-8px] opacity-0" />
<Handle type="source" position={Position.Right} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !right-[-8px] opacity-0" />
</div>
);
}
const nodeTypes = {
agentNode: AgentNodeCard,
};
const layoutDagre = (nodes: Node<AgentNodeData>[], edges: Edge[]): Node<AgentNodeData>[] => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 60 });
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: 260, height: 110 });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
const newNode = { ...node };
if (nodeWithPosition) {
newNode.targetPosition = Position.Left;
newNode.sourcePosition = Position.Right;
newNode.position = {
x: nodeWithPosition.x - 260 / 2,
y: nodeWithPosition.y - 110 / 2,
};
}
return newNode;
});
};
function SpecializedAgentDagInner({ beads, archetypes, selectedId, onSelect }: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
const { fitView } = useReactFlow();
const handleNodeClick = React.useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
const flowModel = useMemo(() => {
// Find visible beads (hide tombstone)
const visibleBeads = beads.filter(b => b.status !== 'tombstone');
const baseNodes: Node<AgentNodeData>[] = visibleBeads.map((issue) => {
const assigneeStr = issue.assignee?.toLowerCase() || '';
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
return {
id: issue.id,
type: 'agentNode',
data: {
title: issue.title,
status: issue.status,
assignee: issue.assignee,
archetype: matchedArchetype,
isSelected: issue.id === selectedId
},
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
const graphEdges: Edge[] = [];
const beadIds = new Set(visibleBeads.map(b => b.id));
const graphModel = buildGraphModel(visibleBeads);
const transitiveEdges = identifyTransitiveEdges(graphModel);
visibleBeads.forEach(issue => {
issue.dependencies.forEach(dep => {
if (dep.type === 'blocks' && beadIds.has(dep.target)) {
// issue depends on dep.target (issue is blocked by dep.target)
// Edge should flow from blocker to blocked
const sourceNode = visibleBeads.find(i => i.id === dep.target);
const isActiveBlocker = sourceNode?.status === 'in_progress' || sourceNode?.status === 'blocked';
const edgeId = `${dep.target}:blocks:${issue.id}`;
const isTransitive = transitiveEdges.has(edgeId);
const isLinked = issue.id === selectedId || dep.target === selectedId;
let stroke = '#475569';
let dashArray: string | undefined = undefined;
let opacity = 0.8;
if (isTransitive && !isLinked) {
stroke = '#334155';
dashArray = '4 4';
opacity = 0.4;
} else if (isActiveBlocker) {
stroke = isLinked ? '#7dd3fc' : '#fbbf24';
opacity = 1;
} else {
stroke = isLinked ? '#7dd3fc' : '#475569';
opacity = isLinked ? 1 : 0.8;
}
graphEdges.push({
id: `e-${dep.target}-${issue.id}`,
source: dep.target,
target: issue.id,
type: 'smoothstep',
animated: isLinked || isActiveBlocker,
style: { stroke, strokeWidth: isLinked ? 2.8 : 2, opacity, strokeDasharray: dashArray },
markerEnd: { type: MarkerType.ArrowClosed, color: stroke }
});
}
});
});
console.log('SpecializedAgentDag generated nodes:', baseNodes.length, 'edges:', graphEdges.length);
return {
nodes: layoutDagre(baseNodes, graphEdges),
edges: graphEdges,
};
}, [beads, archetypes, selectedId]);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 300 });
}, 100);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length]);
return (
<ReactFlow
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
onNodeClick={handleNodeClick}
fitView
>
<Background gap={24} size={1} color="rgba(255,255,255,0.02)" />
</ReactFlow>
);
}
export function SpecializedAgentDag(props: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
return (
<ReactFlowProvider>
<SpecializedAgentDagInner {...props} />
</ReactFlowProvider>
);
}

View file

@ -1,109 +1,109 @@
"use client";
import React from 'react';
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
import type { BeadIssue } from '../../lib/types';
import { FolderGit2 } from 'lucide-react';
import { cn } from '../../lib/utils';
export function SwarmMissionPicker({ issues }: { issues: BeadIssue[] }) {
const { view, setView, setSwarmId, swarmId } = useUrlState();
"use client";
import React from 'react';
import { useUrlState, type ViewType } from '../../hooks/use-url-state';
import type { BeadIssue } from '../../lib/types';
import { FolderGit2 } from 'lucide-react';
import { cn } from '../../lib/utils';
export function SwarmMissionPicker({ issues }: { issues: BeadIssue[] }) {
const { view, setView, setSwarmId, swarmId } = useUrlState();
const views: Array<{ id: ViewType; label: string }> = [
{ id: 'social', label: 'Social' },
{ id: 'graph', label: 'Graph' },
];
// Filter issues to find epics (Missions)
const missions = issues.filter(i => i.issue_type === 'epic' && i.status !== 'closed');
return (
<aside className="flex h-full flex-col bg-[var(--ui-bg-shell)] shadow-[inset_-1px_0_0_rgba(0,0,0,0.55),24px_0_40px_-34px_rgba(0,0,0,0.98)]" data-testid="left-panel-swarm">
<div className="px-4 py-3 shadow-[0_14px_24px_-20px_rgba(0,0,0,0.92)]">
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[#101c2b] p-1 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.88)]">
{views.map((item) => {
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]',
active
? 'bg-[#183149] text-[var(--ui-text-primary)]'
: 'text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)]',
)}
>
{item.label}
</button>
);
})}
</div>
<div className="mt-4 flex items-center gap-2">
<FolderGit2 className="w-4 h-4 text-[var(--ui-accent-info)]" />
<h2 className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--ui-text-muted)]">Active Missions</h2>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{missions.length === 0 ? (
<div className="p-4 text-center text-xs text-[var(--ui-text-muted)] bg-white/5 rounded-lg border border-dashed border-white/10">
No active missions (epics) found.
</div>
) : (
missions.map(m => {
const isSelected = swarmId === m.id;
const hasChildren = m.dependencies.filter(d => d.type === 'parent').length;
const progress = hasChildren > 0 ? 30 : 0;
return (
<button
key={m.id}
// Filter issues to find epics (Missions)
const missions = issues.filter(i => i.issue_type === 'epic' && i.status !== 'closed');
return (
<aside className="flex h-full flex-col bg-[var(--ui-bg-shell)] shadow-[inset_-1px_0_0_rgba(0,0,0,0.55),24px_0_40px_-34px_rgba(0,0,0,0.98)]" data-testid="left-panel-swarm">
<div className="px-4 py-3 shadow-[0_14px_24px_-20px_rgba(0,0,0,0.92)]">
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[#101c2b] p-1 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.88)]">
{views.map((item) => {
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={cn(
'flex-1 rounded-lg px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent-info)]',
active
? 'bg-[#183149] text-[var(--ui-text-primary)]'
: 'text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)]',
)}
>
{item.label}
</button>
);
})}
</div>
<div className="mt-4 flex items-center gap-2">
<FolderGit2 className="w-4 h-4 text-[var(--ui-accent-info)]" />
<h2 className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--ui-text-muted)]">Active Missions</h2>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 custom-scrollbar">
{missions.length === 0 ? (
<div className="p-4 text-center text-xs text-[var(--ui-text-muted)] bg-white/5 rounded-lg border border-dashed border-white/10">
No active missions (epics) found.
</div>
) : (
missions.map(m => {
const isSelected = swarmId === m.id;
const hasChildren = m.dependencies.filter(d => d.type === 'parent').length;
const progress = hasChildren > 0 ? 30 : 0;
return (
<button
key={m.id}
onClick={() => {
setView('graph');
setSwarmId(m.id);
}}
className={cn(
'flex flex-col items-start p-3 min-h-[60px] rounded-xl transition-all w-full focus:outline-none focus:ring-2 focus:ring-[var(--ui-accent-info)] text-left mb-2 shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)]',
isSelected
? 'bg-[#183149] border border-[#2c4e73] ring-1 ring-[#7dd3fc]/20'
: 'bg-[#111f2b] border border-transparent hover:bg-[#152736]'
)}
>
<div className="flex items-center justify-between w-full mb-1">
<span className="font-semibold text-[13px] text-[var(--ui-text-primary)] line-clamp-1">{m.title}</span>
<span className={cn(
'text-[9px] uppercase font-bold px-1.5 py-0.5 rounded',
m.status === 'in_progress' ? 'bg-[var(--ui-accent-warning)]/20 text-[var(--ui-accent-warning)]' : 'bg-white/10 text-[var(--ui-text-muted)]'
)}>
{m.status.replace('_', ' ')}
</span>
</div>
<span className="text-[10px] text-[var(--ui-text-muted)] font-mono mb-2">{m.id}</span>
<div className="w-full bg-[#0a111a] h-1.5 rounded-full overflow-hidden">
<div
className="bg-[var(--ui-accent-info)] h-full rounded-full transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</button>
);
})
)}
</div>
<footer className="border-t border-[var(--ui-border-soft)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-[linear-gradient(135deg,#9cb6bf,#f1dcc6)]" />
<div>
<p className="text-sm font-semibold text-[var(--ui-text-primary)]">Swarm Commander</p>
<p className="text-xs text-[var(--ui-text-muted)]">Operations</p>
</div>
</div>
</footer>
</aside>
);
}
className={cn(
'flex flex-col items-start p-3 min-h-[60px] rounded-xl transition-all w-full focus:outline-none focus:ring-2 focus:ring-[var(--ui-accent-info)] text-left mb-2 shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)]',
isSelected
? 'bg-[#183149] border border-[#2c4e73] ring-1 ring-[#7dd3fc]/20'
: 'bg-[#111f2b] border border-transparent hover:bg-[#152736]'
)}
>
<div className="flex items-center justify-between w-full mb-1">
<span className="font-semibold text-[13px] text-[var(--ui-text-primary)] line-clamp-1">{m.title}</span>
<span className={cn(
'text-[9px] uppercase font-bold px-1.5 py-0.5 rounded',
m.status === 'in_progress' ? 'bg-[var(--ui-accent-warning)]/20 text-[var(--ui-accent-warning)]' : 'bg-white/10 text-[var(--ui-text-muted)]'
)}>
{m.status.replace('_', ' ')}
</span>
</div>
<span className="text-[10px] text-[var(--ui-text-muted)] font-mono mb-2">{m.id}</span>
<div className="w-full bg-[#0a111a] h-1.5 rounded-full overflow-hidden">
<div
className="bg-[var(--ui-accent-info)] h-full rounded-full transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</button>
);
})
)}
</div>
<footer className="border-t border-[var(--ui-border-soft)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-[linear-gradient(135deg,#9cb6bf,#f1dcc6)]" />
<div>
<p className="text-sm font-semibold text-[var(--ui-text-primary)]">Swarm Commander</p>
<p className="text-xs text-[var(--ui-text-muted)]">Operations</p>
</div>
</div>
</footer>
</aside>
);
}

View file

@ -1,191 +1,191 @@
"use client";
import React, { useState } from 'react';
import { Loader2, AlertCircle, Bot, Zap } from 'lucide-react';
"use client";
import React, { useState } from 'react';
import { Loader2, AlertCircle, Bot, Zap } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
import { getArchetypeDisplayChar } from '../../lib/utils';
import { WorkflowGraph } from '../shared/workflow-graph';
interface TelemetryGridProps {
epicId: string;
issues: BeadIssue[];
archetypes: AgentArchetype[];
}
export function TelemetryGrid({ epicId, issues, archetypes }: TelemetryGridProps) {
const [selectedBeadId, setSelectedBeadId] = useState<string | null>(null);
const [isPrepping, setIsPrepping] = useState(false);
const [prepSuccess, setPrepSuccess] = useState(false);
const [selectedArchetypeForPrep, setSelectedArchetypeForPrep] = useState<string>('');
// 1. Filter beads for this epic
const beads = issues.filter(issue => {
if (issue.issue_type === 'epic') return false; // don't include epic itself in DAG
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
// 2. Compute "Attention Feed" (Blocked beads)
const blockedBeads = beads.filter(b => b.status === 'blocked');
// 3. Compute "Active Roster" (Unique assignees working on in_progress beads)
const activeAssignees = new Set<string>();
const rosterEntries: { assignee: string, currentTask: string, archetype?: AgentArchetype }[] = [];
beads.forEach(b => {
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
activeAssignees.add(b.assignee);
const assigneeStr = b.assignee.toLowerCase();
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
rosterEntries.push({
assignee: b.assignee,
currentTask: b.title,
archetype: matchedArchetype
});
}
});
const selectedBead = selectedBeadId ? beads.find(b => b.id === selectedBeadId) : null;
const handlePrepTask = async () => {
if (!selectedBead || !selectedArchetypeForPrep) return;
setIsPrepping(true);
setPrepSuccess(false);
try {
const res = await fetch('/api/swarm/prep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beadId: selectedBead.id,
archetypeId: selectedArchetypeForPrep
})
});
if (!res.ok) throw new Error('Prep failed');
setPrepSuccess(true);
setTimeout(() => setPrepSuccess(false), 3000);
// Note: The shell's useIssues typically polls or relies on SWR to update.
// In a real app we'd call mutate() here.
} catch (e) {
console.error(e);
} finally {
setIsPrepping(false);
}
};
return (
<div className="flex flex-col lg:flex-row gap-4 h-full animate-in fade-in duration-500">
{/* Left/Top: Specialized DAG */}
<div className="flex-[2] min-h-[400px] lg:min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] shadow-inner relative overflow-hidden flex flex-col">
<div className="absolute top-3 left-3 z-10 px-3 py-1.5 bg-background/80 backdrop-blur rounded-md border border-[var(--ui-border-soft)] flex items-center gap-2 shadow-sm pointer-events-none">
<Bot className="w-4 h-4 text-[var(--ui-accent-info)]" />
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--ui-text-primary)]">Agent Flow</span>
</div>
<div className="flex-1 w-full h-full">
<WorkflowGraph
beads={beads}
archetypes={archetypes}
selectedId={selectedBeadId || undefined}
onSelect={setSelectedBeadId}
hideClosed={false}
/>
</div>
</div>
{/* Right/Bottom: Feeds */}
<div className="flex-1 flex flex-col gap-4 min-w-[300px]">
{/* Task Assignment Panel (Shows if a node is selected) */}
{selectedBead && (
<div className="flex-none bg-[#111f2b] rounded-xl border border-[var(--ui-accent-info)]/30 flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)] ring-1 ring-[var(--ui-accent-info)]/10">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<Zap className="w-4 h-4 text-[var(--ui-accent-info)]" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Task Assignment</h3>
</div>
<div className="p-4 space-y-4">
<div>
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-1">{selectedBead.id}</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-snug">{selectedBead.title}</div>
<div className="text-xs text-[var(--ui-text-muted)] mt-1">Status: <span className="font-semibold uppercase">{selectedBead.status}</span></div>
</div>
{(selectedBead.status === 'open' || selectedBead.status === 'blocked') ? (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-[var(--ui-text-muted)] mb-1.5 block">Assign Agent Archetype</label>
<select
value={selectedArchetypeForPrep}
onChange={(e) => setSelectedArchetypeForPrep(e.target.value)}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--ui-accent-info)]"
>
<option value="" disabled>Select archetype...</option>
{archetypes.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<button
onClick={handlePrepTask}
disabled={!selectedArchetypeForPrep || isPrepping || prepSuccess}
className={`w-full py-2 text-white text-sm font-bold rounded-md disabled:opacity-50 transition-colors flex items-center justify-center ${prepSuccess ? 'bg-emerald-500' : 'bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90'}`}
>
{isPrepping ? <Loader2 className="w-4 h-4 animate-spin" /> : prepSuccess ? 'Prep Successful!' : 'Prep Task for Swarm'}
</button>
</div>
) : (
<div className="text-xs text-amber-500 bg-amber-500/10 p-2 rounded border border-amber-500/20">
Task is {selectedBead.status.replace('_', ' ')}. Only open or blocked tasks can be prepped.
</div>
)}
</div>
</div>
)}
{/* Priority Attention */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-rose-500" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Priority Attention</h3>
<span className="ml-auto bg-rose-500/10 text-rose-500 text-[10px] font-bold px-2 py-0.5 rounded-full">{blockedBeads.length} Blocked</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{blockedBeads.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
All clear. No blocked tasks.
</div>
) : (
blockedBeads.map(b => (
<div key={b.id} className="p-3 bg-rose-500/5 border border-rose-500/20 rounded-lg">
<div className="text-xs font-mono text-rose-500 mb-1">{b.id}</div>
<div className="text-sm text-[var(--ui-text-primary)] font-medium leading-tight">{b.title}</div>
</div>
))
)}
</div>
</div>
{/* Active Roster */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Active Roster</h3>
<span className="ml-auto text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider">{rosterEntries.length} Deployed</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{rosterEntries.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
No agents currently active.
</div>
) : (
rosterEntries.map((r, i) => (
import { WorkflowGraph } from '../shared/workflow-graph';
interface TelemetryGridProps {
epicId: string;
issues: BeadIssue[];
archetypes: AgentArchetype[];
}
export function TelemetryGrid({ epicId, issues, archetypes }: TelemetryGridProps) {
const [selectedBeadId, setSelectedBeadId] = useState<string | null>(null);
const [isPrepping, setIsPrepping] = useState(false);
const [prepSuccess, setPrepSuccess] = useState(false);
const [selectedArchetypeForPrep, setSelectedArchetypeForPrep] = useState<string>('');
// 1. Filter beads for this epic
const beads = issues.filter(issue => {
if (issue.issue_type === 'epic') return false; // don't include epic itself in DAG
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
// 2. Compute "Attention Feed" (Blocked beads)
const blockedBeads = beads.filter(b => b.status === 'blocked');
// 3. Compute "Active Roster" (Unique assignees working on in_progress beads)
const activeAssignees = new Set<string>();
const rosterEntries: { assignee: string, currentTask: string, archetype?: AgentArchetype }[] = [];
beads.forEach(b => {
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
activeAssignees.add(b.assignee);
const assigneeStr = b.assignee.toLowerCase();
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
rosterEntries.push({
assignee: b.assignee,
currentTask: b.title,
archetype: matchedArchetype
});
}
});
const selectedBead = selectedBeadId ? beads.find(b => b.id === selectedBeadId) : null;
const handlePrepTask = async () => {
if (!selectedBead || !selectedArchetypeForPrep) return;
setIsPrepping(true);
setPrepSuccess(false);
try {
const res = await fetch('/api/swarm/prep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beadId: selectedBead.id,
archetypeId: selectedArchetypeForPrep
})
});
if (!res.ok) throw new Error('Prep failed');
setPrepSuccess(true);
setTimeout(() => setPrepSuccess(false), 3000);
// Note: The shell's useIssues typically polls or relies on SWR to update.
// In a real app we'd call mutate() here.
} catch (e) {
console.error(e);
} finally {
setIsPrepping(false);
}
};
return (
<div className="flex flex-col lg:flex-row gap-4 h-full animate-in fade-in duration-500">
{/* Left/Top: Specialized DAG */}
<div className="flex-[2] min-h-[400px] lg:min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] shadow-inner relative overflow-hidden flex flex-col">
<div className="absolute top-3 left-3 z-10 px-3 py-1.5 bg-background/80 backdrop-blur rounded-md border border-[var(--ui-border-soft)] flex items-center gap-2 shadow-sm pointer-events-none">
<Bot className="w-4 h-4 text-[var(--ui-accent-info)]" />
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--ui-text-primary)]">Agent Flow</span>
</div>
<div className="flex-1 w-full h-full">
<WorkflowGraph
beads={beads}
archetypes={archetypes}
selectedId={selectedBeadId || undefined}
onSelect={setSelectedBeadId}
hideClosed={false}
/>
</div>
</div>
{/* Right/Bottom: Feeds */}
<div className="flex-1 flex flex-col gap-4 min-w-[300px]">
{/* Task Assignment Panel (Shows if a node is selected) */}
{selectedBead && (
<div className="flex-none bg-[#111f2b] rounded-xl border border-[var(--ui-accent-info)]/30 flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)] ring-1 ring-[var(--ui-accent-info)]/10">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<Zap className="w-4 h-4 text-[var(--ui-accent-info)]" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Task Assignment</h3>
</div>
<div className="p-4 space-y-4">
<div>
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-1">{selectedBead.id}</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-snug">{selectedBead.title}</div>
<div className="text-xs text-[var(--ui-text-muted)] mt-1">Status: <span className="font-semibold uppercase">{selectedBead.status}</span></div>
</div>
{(selectedBead.status === 'open' || selectedBead.status === 'blocked') ? (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-[var(--ui-text-muted)] mb-1.5 block">Assign Agent Archetype</label>
<select
value={selectedArchetypeForPrep}
onChange={(e) => setSelectedArchetypeForPrep(e.target.value)}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--ui-accent-info)]"
>
<option value="" disabled>Select archetype...</option>
{archetypes.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<button
onClick={handlePrepTask}
disabled={!selectedArchetypeForPrep || isPrepping || prepSuccess}
className={`w-full py-2 text-white text-sm font-bold rounded-md disabled:opacity-50 transition-colors flex items-center justify-center ${prepSuccess ? 'bg-emerald-500' : 'bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90'}`}
>
{isPrepping ? <Loader2 className="w-4 h-4 animate-spin" /> : prepSuccess ? 'Prep Successful!' : 'Prep Task for Swarm'}
</button>
</div>
) : (
<div className="text-xs text-amber-500 bg-amber-500/10 p-2 rounded border border-amber-500/20">
Task is {selectedBead.status.replace('_', ' ')}. Only open or blocked tasks can be prepped.
</div>
)}
</div>
</div>
)}
{/* Priority Attention */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-rose-500" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Priority Attention</h3>
<span className="ml-auto bg-rose-500/10 text-rose-500 text-[10px] font-bold px-2 py-0.5 rounded-full">{blockedBeads.length} Blocked</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{blockedBeads.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
All clear. No blocked tasks.
</div>
) : (
blockedBeads.map(b => (
<div key={b.id} className="p-3 bg-rose-500/5 border border-rose-500/20 rounded-lg">
<div className="text-xs font-mono text-rose-500 mb-1">{b.id}</div>
<div className="text-sm text-[var(--ui-text-primary)] font-medium leading-tight">{b.title}</div>
</div>
))
)}
</div>
</div>
{/* Active Roster */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Active Roster</h3>
<span className="ml-auto text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider">{rosterEntries.length} Deployed</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{rosterEntries.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
No agents currently active.
</div>
) : (
rosterEntries.map((r, i) => (
<div key={i} className="flex gap-3 p-3 bg-[#0a111a] border border-white/5 rounded-lg items-center">
<div
className="h-8 w-8 rounded flex-shrink-0 flex items-center justify-center font-bold text-sm border"
@ -193,17 +193,17 @@ export function TelemetryGrid({ epicId, issues, archetypes }: TelemetryGridProps
>
{r.archetype ? getArchetypeDisplayChar(r.archetype) : r.assignee.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold text-[var(--ui-text-primary)] truncate">{r.assignee}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] truncate">{r.currentTask}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
<div className="min-w-0 flex-1">
<div className="text-xs font-bold text-[var(--ui-text-primary)] truncate">{r.assignee}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] truncate">{r.currentTask}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}