mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 12:47:51 +00:00
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
This commit is contained in:
parent
3efa474eb0
commit
833ece2509
6 changed files with 355 additions and 1 deletions
|
|
@ -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() {
|
|||
<header className="border-b border-border bg-card">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">⚖️</span>
|
||||
<h1 className="text-lg font-semibold">Veritas Kanban</h1>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" aria-hidden="true" />
|
||||
<AgentStatusIndicator />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
306
web/src/components/shared/AgentStatusIndicator.tsx
Normal file
306
web/src/components/shared/AgentStatusIndicator.tsx
Normal file
|
|
@ -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<AgentState, StateConfig> = {
|
||||
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<string | null>(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 (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full bg-gray-400 opacity-50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`agent-status-container relative flex items-center gap-2 ${className}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{/* The pulsing dot */}
|
||||
<div
|
||||
className={`agent-status-dot w-2.5 h-2.5 rounded-full ${animationClass}`}
|
||||
style={{
|
||||
backgroundColor: config.color,
|
||||
color: config.color,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Sub-agent count badge (only for subagents state) */}
|
||||
{state === 'subagents' && data?.subAgentCount && data.subAgentCount > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold rounded-full"
|
||||
style={{
|
||||
backgroundColor: config.bgColor,
|
||||
color: config.color,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{data.subAgentCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className="agent-status-tooltip text-sm text-popover-foreground"
|
||||
role="tooltip"
|
||||
>
|
||||
{tooltipContent.map((line, i) => (
|
||||
<div key={i} className={i > 0 ? 'mt-1' : ''}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
web/src/components/shared/index.ts
Normal file
2
web/src/components/shared/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { AgentStatusIndicator } from './AgentStatusIndicator';
|
||||
export { FeatureErrorBoundary } from './FeatureErrorBoundary';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
26
web/src/hooks/useGlobalAgentStatus.ts
Normal file
26
web/src/hooks/useGlobalAgentStatus.ts
Normal file
|
|
@ -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 };
|
||||
|
|
@ -312,6 +312,12 @@ export const api = {
|
|||
},
|
||||
|
||||
agent: {
|
||||
// Global agent status (not per-task)
|
||||
globalStatus: async (): Promise<GlobalAgentStatus> => {
|
||||
const response = await fetch(`${API_BASE}/agent/status`);
|
||||
return handleResponse<GlobalAgentStatus>(response);
|
||||
},
|
||||
|
||||
start: async (taskId: string, agent?: AgentType): Promise<AgentStatus> => {
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue