From fbfe393f6d95db3ab28e753c218f154e40b15250 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Tue, 24 Feb 2026 16:25:45 -0800 Subject: [PATCH] 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. --- package.json | 2 +- src/app/globals.css | 19 +- src/components/activity/activity-panel.tsx | 76 ++++---- .../activity/contextual-right-panel.tsx | 32 ++++ .../activity/swarm-command-feed.tsx | 170 ++++++++++++++++++ .../graph/dependency-graph-page.tsx | 1 + src/components/shared/left-panel.tsx | 17 +- src/components/shared/mobile-nav.tsx | 1 - src/components/swarm/swarm-mission-picker.tsx | 5 +- src/components/swarm/telemetry-grid.tsx | 19 +- src/hooks/use-url-state.ts | 4 +- tests/components/shared/mobile-nav.test.tsx | 2 +- tests/components/shared/top-bar.test.tsx | 2 +- tests/hooks/url-state-integration.test.ts | 15 +- 14 files changed, 280 insertions(+), 85 deletions(-) create mode 100644 src/components/activity/contextual-right-panel.tsx create mode 100644 src/components/activity/swarm-command-feed.tsx diff --git a/package.json b/package.json index b132131..972b5e6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts", + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx", "video": "remotion preview src/video/index.ts", "video:render": "remotion render src/video/index.ts Main out/video.mp4", "video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60" diff --git a/src/app/globals.css b/src/app/globals.css index 0259642..7b02e5d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -84,8 +84,10 @@ /* ========== RADI ========== */ --radius-sm: 0.375rem; - --radius-card: 1.5rem; /* rounded-3xl for soft feel */ - --radius-xl: 1.5rem; /* rounded-3xl */ + --radius-card: 1.5rem; + /* rounded-3xl for soft feel */ + --radius-xl: 1.5rem; + /* rounded-3xl */ --radius-modal: 1rem; --radius-pill: 9999px; @@ -98,7 +100,7 @@ /* ========== TYPOGRAPHY ========== */ --font-ui-stack: var(--ui-font-sans); --font-mono-stack: var(--ui-font-mono); - + --font-size-h1: 2rem; --font-size-h2: 1.5rem; --font_size-h3: 1.125rem; @@ -131,11 +133,9 @@ --sidebar-right-width: 17.5rem; --topbar-height: 3.75rem; - --glass-base: linear-gradient( - 180deg, - color-mix(in srgb, var(--ui-bg-card) 72%, black), - color-mix(in srgb, var(--ui-bg-panel) 78%, black) - ); + --glass-base: linear-gradient(180deg, + color-mix(in srgb, var(--ui-bg-card) 72%, black), + color-mix(in srgb, var(--ui-bg-panel) 78%, black)); --edge-top: color-mix(in srgb, var(--ui-border-strong) 80%, white 20%); --edge-bottom: color-mix(in srgb, var(--ui-border-soft) 75%, black 25%); --elevation-ambient: 0 20px 40px -16px rgba(0, 0, 0, 0.78); @@ -387,8 +387,9 @@ body { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } -} +} \ No newline at end of file diff --git a/src/components/activity/activity-panel.tsx b/src/components/activity/activity-panel.tsx index 776c7a0..8df8023 100644 --- a/src/components/activity/activity-panel.tsx +++ b/src/components/activity/activity-panel.tsx @@ -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 = { 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([]); 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
{getInitials(agent.name)} @@ -323,17 +323,17 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
))} - +
- + {/* Activity Pulses */}
- {activities.slice(0, 8).map((act) => ( -
- ))} + {activities.slice(0, 8).map((act) => ( +
+ ))}
); @@ -352,7 +352,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi {activeAgents} ONLINE
- + {agentRoster.length === 0 ? (

No agents broadcasting

) : ( @@ -392,14 +392,14 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi )} - + {/* ACTIVITY FEED SECTION */}

Telemetry Stream

- + {isLoading ? (
@@ -432,11 +432,11 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi {formatRelativeTime(activity.timestamp)}
- +

{activity.beadTitle}

- +
{activity.beadId} diff --git a/src/components/activity/contextual-right-panel.tsx b/src/components/activity/contextual-right-panel.tsx new file mode 100644 index 0000000..1fd06be --- /dev/null +++ b/src/components/activity/contextual-right-panel.tsx @@ -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 ( + + ); + } + + // Fallback to Global feed + return ( + + ); +} diff --git a/src/components/activity/swarm-command-feed.tsx b/src/components/activity/swarm-command-feed.tsx new file mode 100644 index 0000000..1bf416a --- /dev/null +++ b/src/components/activity/swarm-command-feed.tsx @@ -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([]); + 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(); + 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 ( +
+ {/* SQUAD ROSTER SECTION */} +
+
+
+
+

Active Squad

+
+
+ {rosterEntries.length} DEPLOYED +
+
+ + {rosterEntries.length === 0 ? ( +
+ No agents currently operating on this Epic. +
+ ) : ( +
+ {rosterEntries.map((agent, i) => ( +
+
+
+ + + {getInitials(agent.assignee)} + + +
+
+
{agent.assignee}
+
+ > {agent.currentTask} +
+
+
+ ))} +
+ )} +
+ + {/* STREAMING LOG / TERMINAL SECTION */} +
+
+
+ +

Live Command Feed

+
+
Tailing Logs
+
+ + + {activities.length === 0 ? ( +
+
+

Waiting for agent signals...

+
+ ) : ( +
+ {activities.map((activity) => { + const eventTone = getEventTone(activity.kind); + return ( +
+
+ [{formatRelativeTime(activity.timestamp)}] +
+
+
+ {activity.actor && ( + {activity.actor.split(' ')[0]} + )} + + {eventTone.label.toLowerCase()} + + + {activity.beadId} + +
+
+ {activity.beadTitle} +
+
+
+ ); + })} +
+ )} + +
+
+ ); +} diff --git a/src/components/graph/dependency-graph-page.tsx b/src/components/graph/dependency-graph-page.tsx index e372366..61a0f42 100644 --- a/src/components/graph/dependency-graph-page.tsx +++ b/src/components/graph/dependency-graph-page.tsx @@ -533,6 +533,7 @@ export function DependencyGraphPage({ isCycleNode: cycleNodeIdSet.has(issue.id), isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false, blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [], + labels: issue.labels, }, position: { x: 0, y: 0 }, sourcePosition: Position.Right, diff --git a/src/components/shared/left-panel.tsx b/src/components/shared/left-panel.tsx index f5f4113..ad21774 100644 --- a/src/components/shared/left-panel.tsx +++ b/src/components/shared/left-panel.tsx @@ -187,11 +187,10 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, filters, onFil const views: Array<{ id: ViewType; label: string }> = [ { id: 'social', label: 'Social' }, { id: 'graph', label: 'Graph' }, - { id: 'swarm', label: 'Swarm' }, ]; return ( -
diff --git a/src/hooks/use-url-state.ts b/src/hooks/use-url-state.ts index 6bb7f7e..0fdad82 100644 --- a/src/hooks/use-url-state.ts +++ b/src/hooks/use-url-state.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; -export type ViewType = 'social' | 'graph' | 'swarm' | 'activity'; +export type ViewType = 'social' | 'graph' | 'activity'; export type PanelState = 'open' | 'closed'; export type DrawerState = 'open' | 'closed'; export type GraphTabType = 'flow' | 'overview'; @@ -43,7 +43,7 @@ const DEFAULT_RIGHT_PANEL: PanelState = 'open'; const DEFAULT_DRAWER: DrawerState = 'closed'; const DEFAULT_GRAPH_TAB: GraphTabType = 'flow'; -const VALID_VIEWS: ViewType[] = ['social', 'graph', 'swarm', 'activity']; +const VALID_VIEWS: ViewType[] = ['social', 'graph', 'activity']; const VALID_PANELS: PanelState[] = ['open', 'closed']; const VALID_DRAWERS: DrawerState[] = ['open', 'closed']; const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview']; diff --git a/tests/components/shared/mobile-nav.test.tsx b/tests/components/shared/mobile-nav.test.tsx index a993f80..92e1903 100644 --- a/tests/components/shared/mobile-nav.test.tsx +++ b/tests/components/shared/mobile-nav.test.tsx @@ -11,7 +11,7 @@ describe('Mobile Navigation - Hamburger Menu', () => { } }); - it('renders three tab buttons: Social, Graph, Swarm', async () => { + it('renders tab buttons: Social, Graph', async () => { try { const mod = await import('../../../src/components/shared/mobile-nav'); assert.ok(mod.MobileNav, 'MobileNav should exist'); diff --git a/tests/components/shared/top-bar.test.tsx b/tests/components/shared/top-bar.test.tsx index b760ea4..eb7453e 100644 --- a/tests/components/shared/top-bar.test.tsx +++ b/tests/components/shared/top-bar.test.tsx @@ -23,7 +23,7 @@ describe('TopBar Component Contract', () => { }); describe('TopBar View Tabs', () => { - it('renders three view tabs: Social, Graph, Swarm', async () => { + it('renders view tabs: Social, Graph', async () => { try { const mod = await import('../../../src/components/shared/top-bar'); assert.ok(mod.TopBar, 'TopBar should exist'); diff --git a/tests/hooks/url-state-integration.test.ts b/tests/hooks/url-state-integration.test.ts index 2feb228..8494a54 100644 --- a/tests/hooks/url-state-integration.test.ts +++ b/tests/hooks/url-state-integration.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { parseUrlState, buildUrlParams, type ViewType, type GraphTabType } from '../../src/hooks/use-url-state'; +import { parseUrlState, buildUrlParams } from '../../src/hooks/use-url-state'; /** * URL State Integration Tests - bb-ui2.22 @@ -80,23 +80,24 @@ describe('URL State Integration - bb-ui2.22', () => { }); }); - describe('Valid URL Patterns - Swarm View', () => { - it('/?view=swarm - swarm view default', () => { + describe('Deprecated Swarm View Fallback', () => { + it('/?view=swarm - falls back to social (swarm view deprecated)', () => { const sp = createMockSearchParams({ view: 'swarm' }); const state = parseUrlState(sp); - assert.strictEqual(state.view, 'swarm'); + assert.strictEqual(state.view, 'social'); }); - it('/?view=swarm&swarm=bb-buff - specific swarm selected', () => { + it('/?view=swarm&swarm=bb-buff - falls back to social but preserves swarmId', () => { const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff' }); const state = parseUrlState(sp); - assert.strictEqual(state.view, 'swarm'); + assert.strictEqual(state.view, 'social'); assert.strictEqual(state.swarmId, 'bb-buff'); }); - it('/?view=swarm&swarm=bb-buff&panel=open - swarm with panel open', () => { + it('/?view=swarm&swarm=bb-buff&panel=open - falls back to social with panel open', () => { const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff', panel: 'open' }); const state = parseUrlState(sp); + assert.strictEqual(state.view, 'social'); assert.strictEqual(state.swarmId, 'bb-buff'); assert.strictEqual(state.panel, 'open'); });