chore: checkpoint related UI improvements and supporting components

Various supporting changes made during the assign archetypes feature development:

- Added contextual-right-panel.tsx and swarm-command-feed.tsx
- Updated activity-panel.tsx with new features
- UI improvements to left-panel, mobile-nav
- Test updates for url-state-integration, mobile-nav, top-bar
- Package.json updates for dependencies
- Global CSS refinements

These changes support the main assign archetypes feature but are
not directly part of its core functionality.
This commit is contained in:
zenchantlive 2026-02-24 16:25:45 -08:00
parent 30f5f67216
commit fbfe393f6d
14 changed files with 280 additions and 85 deletions

View file

@ -16,7 +16,7 @@ type AgentTone = {
glowClass: string;
};
type EventTone = {
export type EventTone = {
label: string;
labelClass: string;
dotClass: string;
@ -42,11 +42,11 @@ 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';
@ -57,25 +57,25 @@ function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
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) ||
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,
@ -92,19 +92,19 @@ function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
}
// Format relative time
function formatRelativeTime(timestamp: string): string {
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' });
}
@ -140,7 +140,7 @@ function getAgentTone(status: AgentStatus): AgentTone {
}
// reopened=blue, closed=amber, created/opened=green, others semantic
function getEventTone(kind: string): EventTone {
export function getEventTone(kind: string): EventTone {
const normalized = kind.toLowerCase();
const byKind: Record<string, EventTone> = {
created: {
@ -240,16 +240,16 @@ function getEventTone(kind: string): EventTone {
);
}
function getInitials(name: string): string {
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() {
@ -265,15 +265,15 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
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);
@ -286,9 +286,9 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
// Ignore parse errors
}
};
source.addEventListener('activity', onActivity as EventListener);
return () => {
console.log('[ActivityPanel] Closing SSE connection');
source.removeEventListener('activity', onActivity as EventListener);
@ -307,14 +307,14 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
<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'
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'
agent.status === 'stale' ? 'ring-[#D4A574]/45' :
agent.status === 'stuck' ? 'ring-[#C97A7A]/45' : 'ring-[#A78A94]/40'
)}>
<AvatarFallback className="text-[10px] font-bold bg-[#1a1a1a] text-text-muted">
{getInitials(agent.name)}
@ -323,17 +323,17 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
</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
)} />
))}
{activities.slice(0, 8).map((act) => (
<div key={act.id} className={cn(
"w-1 h-1 rounded-full",
getEventTone(act.kind).dotClass
)} />
))}
</div>
</div>
);
@ -352,7 +352,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
{activeAgents} ONLINE
</div>
</div>
{agentRoster.length === 0 ? (
<p className="text-xs text-text-muted/40 italic text-center py-4">No agents broadcasting</p>
) : (
@ -392,14 +392,14 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
</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">
@ -432,11 +432,11 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
{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}

View file

@ -0,0 +1,32 @@
'use client';
import React from 'react';
import type { BeadIssue } from '../../lib/types';
import { ActivityPanel } from './activity-panel';
import { SwarmCommandFeed } from './swarm-command-feed';
export interface ContextualRightPanelProps {
epicId?: string | null;
issues: BeadIssue[];
projectRoot: string;
}
export function ContextualRightPanel({ epicId, issues, projectRoot }: ContextualRightPanelProps) {
if (epicId) {
return (
<SwarmCommandFeed
epicId={epicId}
issues={issues}
projectRoot={projectRoot}
/>
);
}
// Fallback to Global feed
return (
<ActivityPanel
issues={issues}
projectRoot={projectRoot}
/>
);
}

View file

@ -0,0 +1,170 @@
'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 } 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. 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-[#050a10] border-l border-[var(--ui-border-soft)]">
{/* SQUAD ROSTER SECTION */}
<div className="flex-shrink-0 p-4 bg-[#0a111a] 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-[#0f1824] 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' }}>
{getInitials(agent.assignee)}
</AvatarFallback>
</Avatar>
</div>
<div className="flex-col flex-1 min-w-0">
<div className="text-sm font-bold text-[var(--ui-text-primary)] truncate">{agent.assignee}</div>
<div className="text-[10px] text-[var(--ui-accent-warning)] truncate font-mono mt-0.5">
&gt; {agent.currentTask}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* STREAMING LOG / TERMINAL SECTION */}
<div className="flex-1 min-h-0 flex flex-col pt-2 bg-black/40">
<div className="px-4 py-2 flex items-center justify-between border-b border-[var(--ui-border-soft)]/50">
<div className="flex items-center gap-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-cyan-500"><path d="M4 17l6-6-6-6M12 19h8"></path></svg>
<h3 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-[var(--ui-text-muted)]">Live Command Feed</h3>
</div>
<div className="text-[9px] font-mono text-[var(--ui-text-muted)]/50 uppercase">Tailing Logs</div>
</div>
<ScrollArea className="flex-1 p-3">
{activities.length === 0 ? (
<div className="p-10 text-center opacity-30 flex flex-col items-center gap-2">
<div className="w-1 h-4 bg-cyan-500 animate-pulse" />
<p className="text-[10px] font-mono uppercase tracking-widest text-cyan-500">Waiting for agent signals...</p>
</div>
) : (
<div className="space-y-1">
{activities.map((activity) => {
const eventTone = getEventTone(activity.kind);
return (
<div key={activity.id} className="group flex gap-3 p-1.5 rounded bg-transparent hover:bg-white/5 transition-colors items-start">
<div className={cn("text-[9px] font-mono whitespace-nowrap pt-0.5", eventTone.idClass)}>
[{formatRelativeTime(activity.timestamp)}]
</div>
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex items-center gap-1.5 flex-wrap">
{activity.actor && (
<span className="text-[10px] font-bold text-white uppercase">{activity.actor.split(' ')[0]}</span>
)}
<span className={cn("text-[10px] font-mono", eventTone.labelClass)}>
{eventTone.label.toLowerCase()}
</span>
<span className="text-[10px] text-zinc-400 font-mono truncate max-w-[120px]">
{activity.beadId}
</span>
</div>
<div className="text-[11px] text-zinc-300 leading-snug break-words">
{activity.beadTitle}
</div>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
</div>
);
}