From 833ece2509d630bcccbf6a37f7ef08c0477c8676 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 07:38:28 -0600 Subject: [PATCH] feat(web): add AgentStatusIndicator component (US-1302) - Create AgentStatusIndicator with 5 states: idle, working, thinking, subagents, error - Pulsing dot design with CSS animations (pulse, breathe, ripple, flash) - Smooth color transitions between states - Tooltip shows active task, duration, sub-agent count - Accessible: aria-live for state changes - Respects prefers-reduced-motion - Add useGlobalAgentStatus hook polling /api/agent/status - Integrate into Header component --- web/src/components/layout/Header.tsx | 5 +- .../shared/AgentStatusIndicator.tsx | 306 ++++++++++++++++++ web/src/components/shared/index.ts | 2 + web/src/hooks/index.ts | 1 + web/src/hooks/useGlobalAgentStatus.ts | 26 ++ web/src/lib/api.ts | 16 + 6 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 web/src/components/shared/AgentStatusIndicator.tsx create mode 100644 web/src/components/shared/index.ts create mode 100644 web/src/hooks/useGlobalAgentStatus.ts diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 520b9092..0b09d1d9 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -4,6 +4,7 @@ import { CreateTaskDialog } from '@/components/task/CreateTaskDialog'; import { SettingsDialog } from '@/components/settings/SettingsDialog'; import { ActivitySidebar } from './ActivitySidebar'; import { ArchiveSidebar } from './ArchiveSidebar'; +import { AgentStatusIndicator } from '@/components/shared/AgentStatusIndicator'; import { useState } from 'react'; import { useKeyboard } from '@/hooks/useKeyboard'; @@ -21,11 +22,13 @@ export function Header() {
-
+
⚖️

Veritas Kanban

+
diff --git a/web/src/components/shared/AgentStatusIndicator.tsx b/web/src/components/shared/AgentStatusIndicator.tsx new file mode 100644 index 00000000..5343fc1b --- /dev/null +++ b/web/src/components/shared/AgentStatusIndicator.tsx @@ -0,0 +1,306 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useGlobalAgentStatus } from '@/hooks/useGlobalAgentStatus'; + +type AgentState = 'idle' | 'working' | 'thinking' | 'subagents' | 'error'; + +interface StateConfig { + color: string; + bgColor: string; + animation: string; + label: string; +} + +const STATE_CONFIG: Record = { + idle: { + color: '#6b7280', + bgColor: 'rgba(107, 114, 128, 0.2)', + animation: '', + label: 'Idle', + }, + working: { + color: '#22c55e', + bgColor: 'rgba(34, 197, 94, 0.2)', + animation: 'pulse', + label: 'Working...', + }, + thinking: { + color: '#f59e0b', + bgColor: 'rgba(245, 158, 11, 0.2)', + animation: 'breathe', + label: 'Thinking...', + }, + subagents: { + color: '#3b82f6', + bgColor: 'rgba(59, 130, 246, 0.2)', + animation: 'ripple', + label: 'agents', + }, + error: { + color: '#ef4444', + bgColor: 'rgba(239, 68, 68, 0.2)', + animation: 'flash', + label: 'Error', + }, +}; + +// CSS keyframes for animations +const styles = ` + @keyframes agent-pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.15); + opacity: 0.8; + } + } + + @keyframes agent-breathe { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 0 0 currentColor; + } + 50% { + transform: scale(1.05); + box-shadow: 0 0 8px 2px currentColor; + } + } + + @keyframes agent-ripple { + 0% { + box-shadow: 0 0 0 0 currentColor; + } + 70% { + box-shadow: 0 0 0 6px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } + } + + @keyframes agent-flash { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } + } + + .agent-status-dot { + transition: background-color 300ms ease, color 300ms ease; + } + + .agent-status-dot.animate-pulse { + animation: agent-pulse 1.5s ease-in-out infinite; + } + + .agent-status-dot.animate-breathe { + animation: agent-breathe 2s ease-in-out infinite; + } + + .agent-status-dot.animate-ripple { + animation: agent-ripple 1.2s ease-out infinite; + } + + .agent-status-dot.animate-flash { + animation: agent-flash 0.4s ease-in-out 2; + } + + @media (prefers-reduced-motion: reduce) { + .agent-status-dot { + animation: none !important; + } + } + + .agent-status-tooltip { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 8px; + padding: 8px 12px; + background: hsl(var(--popover)); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + white-space: nowrap; + z-index: 50; + opacity: 0; + visibility: hidden; + transition: opacity 150ms ease, visibility 150ms ease; + pointer-events: none; + } + + .agent-status-container:hover .agent-status-tooltip, + .agent-status-container:focus-within .agent-status-tooltip { + opacity: 1; + visibility: visible; + } +`; + +function formatDuration(startTime: string): string { + const start = new Date(startTime); + const now = new Date(); + const seconds = Math.floor((now.getTime() - start.getTime()) / 1000); + + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; +} + +interface AgentStatusIndicatorProps { + className?: string; +} + +export function AgentStatusIndicator({ className = '' }: AgentStatusIndicatorProps) { + const { data, isLoading, error } = useGlobalAgentStatus(); + const [hasFlashed, setHasFlashed] = useState(false); + const [lastStatus, setLastStatus] = useState(null); + + // Inject styles once + useEffect(() => { + const styleId = 'agent-status-indicator-styles'; + if (!document.getElementById(styleId)) { + const styleEl = document.createElement('style'); + styleEl.id = styleId; + styleEl.textContent = styles; + document.head.appendChild(styleEl); + } + }, []); + + // Determine the visual state + const state: AgentState = useMemo(() => { + if (error) return 'error'; + if (!data) return 'idle'; + if (data.status === 'error') return 'error'; + if (data.subAgentCount > 0) return 'subagents'; + return data.status; + }, [data, error]); + + const config = STATE_CONFIG[state]; + + // Reset flash animation when error occurs + useEffect(() => { + if (state === 'error' && lastStatus !== 'error') { + setHasFlashed(false); + // Mark as flashed after animation completes + const timer = setTimeout(() => setHasFlashed(true), 800); + return () => clearTimeout(timer); + } + setLastStatus(state); + }, [state, lastStatus]); + + // Get animation class + const animationClass = useMemo(() => { + if (state === 'error' && hasFlashed) return ''; + if (state === 'idle') return ''; + return config.animation ? `animate-${config.animation}` : ''; + }, [state, hasFlashed, config.animation]); + + // Build label + const label = useMemo(() => { + if (state === 'subagents' && data?.subAgentCount) { + return `${data.subAgentCount} ${config.label}`; + } + return config.label; + }, [state, data?.subAgentCount, config.label]); + + // Build tooltip content + const tooltipContent = useMemo(() => { + const lines: string[] = []; + + if (data?.activeTaskTitle) { + lines.push(`Task: ${data.activeTaskTitle}`); + } + + if (data?.lastUpdated && state !== 'idle') { + lines.push(`Duration: ${formatDuration(data.lastUpdated)}`); + } + + if (data?.subAgentCount && data.subAgentCount > 0) { + lines.push(`Sub-agents: ${data.subAgentCount}`); + } + + if (data?.error) { + lines.push(`Error: ${data.error}`); + } + + return lines.length > 0 ? lines : ['Agent is idle']; + }, [data, state]); + + // Screen reader announcement + const ariaLabel = useMemo(() => { + let announcement = `Agent status: ${label}`; + if (data?.activeTaskTitle) { + announcement += `, working on ${data.activeTaskTitle}`; + } + return announcement; + }, [label, data?.activeTaskTitle]); + + if (isLoading && !data) { + return ( +
+ + ); + } + + return ( +
+ {/* The pulsing dot */} + + ); +} diff --git a/web/src/components/shared/index.ts b/web/src/components/shared/index.ts new file mode 100644 index 00000000..e707522a --- /dev/null +++ b/web/src/components/shared/index.ts @@ -0,0 +1,2 @@ +export { AgentStatusIndicator } from './AgentStatusIndicator'; +export { FeatureErrorBoundary } from './FeatureErrorBoundary'; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index 78dec1ae..a3a0138d 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -13,6 +13,7 @@ export * from './useDebouncedSave'; export * from './useDiff'; export * from './useFeatureSettings'; export * from './useGitHub'; +export * from './useGlobalAgentStatus'; export * from './useKeyboard'; export * from './useManagedList'; export * from './useMetrics'; diff --git a/web/src/hooks/useGlobalAgentStatus.ts b/web/src/hooks/useGlobalAgentStatus.ts new file mode 100644 index 00000000..87076438 --- /dev/null +++ b/web/src/hooks/useGlobalAgentStatus.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, GlobalAgentStatus } from '@/lib/api'; + +/** + * Hook to fetch global agent status (not per-task) + * Polls every 3 seconds when agent is working, less frequently when idle + */ +export function useGlobalAgentStatus() { + return useQuery({ + queryKey: ['agent', 'global-status'], + queryFn: () => api.agent.globalStatus(), + refetchInterval: (query) => { + const status = query.state.data?.status; + // Poll more frequently when active + if (status === 'working' || status === 'thinking') { + return 2000; + } + // Poll less frequently when idle + return 10000; + }, + // Keep previous data during refetch for smoother UX + placeholderData: (previousData) => previousData, + }); +} + +export type { GlobalAgentStatus }; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8f078615..acb0b121 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -312,6 +312,12 @@ export const api = { }, agent: { + // Global agent status (not per-task) + globalStatus: async (): Promise => { + const response = await fetch(`${API_BASE}/agent/status`); + return handleResponse(response); + }, + start: async (taskId: string, agent?: AgentType): Promise => { const response = await fetch(`${API_BASE}/agents/${taskId}/start`, { method: 'POST', @@ -760,6 +766,16 @@ export interface AgentOutput { timestamp: string; } +// Global agent status (not per-task) +export interface GlobalAgentStatus { + status: 'idle' | 'working' | 'thinking' | 'error'; + subAgentCount: number; + activeTask?: string; + activeTaskTitle?: string; + lastUpdated: string; + error?: string; +} + // Types for worktree export interface WorktreeInfo { path: string;