checkpoint: pre-split branch cleanup
This commit is contained in:
parent
4c2ae2e5b7
commit
b5db7a7753
276 changed files with 35912 additions and 60119 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
> {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">
|
||||
> {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
|
|
@ -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 • {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 • {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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 •{' '}
|
||||
<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 •{' '}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
82
src/components/graph/offset-edge.tsx
Normal file
82
src/components/graph/offset-edge.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
"{card.communication.latestSnippet}"
|
||||
</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">
|
||||
"{card.communication.latestSnippet}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue