import "./ExecutorStatusBar.css"; import { useMemo, useState } from "react"; import { HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, type Task, } from "@fusion/core"; import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react"; import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout"; import { useExecutorStats } from "../hooks/useExecutorStats"; import type { ExecutorState, AiSessionSummary } from "../api"; import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator"; interface ExecutorStatusBarProps { /** Task list (shared with the board to keep counts in sync) */ tasks: Task[]; /** Project ID for fetching project-specific stats */ projectId?: string; /** Project-level stuck task timeout in milliseconds (undefined = disabled) */ taskStuckTimeoutMs?: number; /** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */ staleHighFanoutBlockerAgeThresholdMs?: number; /** Background AI sessions */ backgroundSessions?: AiSessionSummary[]; backgroundGenerating?: number; backgroundNeedsInput?: number; onOpenBackgroundSession?: (session: AiSessionSummary) => void; onDismissBackgroundSession?: (id: string) => void; /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; /** Absolute path for the currently selected project directory. */ currentProjectPath?: string; /** Opens the workspace-aware file browser to the project workspace. */ onOpenProjectDirectory?: () => void; /** When true on mobile, force bottom pinning so ICB compensation does not * push the bar above the keyboard; keyboard may cover it instead. */ keyboardOpen?: boolean; /** iOS-only hide guard to prevent footer drifting over content while * visualViewport settles during keyboard transitions. */ hideWhenKeyboardOpen?: boolean; } /** * Format a relative time string (e.g., "2m ago", "1h ago") */ function formatRelativeTime(timestamp: string | undefined): string { if (!timestamp) return "no activity"; const now = Date.now(); const then = new Date(timestamp).getTime(); const diffMs = now - then; const seconds = Math.floor(diffMs / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ago`; if (hours > 0) return `${hours}h ago`; if (minutes > 0) return `${minutes}m ago`; if (seconds > 10) return `${seconds}s ago`; return "just now"; } /** * Get display configuration for an executor state */ function getStateDisplay(state: ExecutorState): { label: string; color: string; icon: typeof Play } { switch (state) { case "running": return { label: "Running", color: "var(--color-success)", icon: Play }; case "paused": return { label: "Paused", color: "var(--triage)", icon: Pause }; case "idle": default: return { label: "Idle", color: "var(--text-muted)", icon: Zap }; } } /** * Footer status bar component that displays real-time executor statistics. * * Shows: * - Running tasks count with pulsing animation when > 0 * - Blocked tasks count with warning color when > 0 * - Queued tasks count * - Executor state badge (idle/running/paused) * - Last activity timestamp */ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen }: ExecutorStatusBarProps) { if (hideWhenKeyboardOpen) return null; const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs); const [isProjectPathVisible, setIsProjectPathVisible] = useState(false); const stateDisplay = useMemo(() => getStateDisplay(stats.executorState), [stats.executorState]); const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt), [stats.lastActivityAt]); const highestOverlapBlocker = useMemo(() => { const fanoutMap = computeBlockerFanoutMap(tasks, { staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs ?? STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, }); const candidates = Array.from(fanoutMap.entries()) .map(([blockerId, entry]) => ({ blockerId, entry })) .filter(({ entry }) => entry.isHighFanout) .sort((a, b) => { if (b.entry.overlapBlockedTodoCount !== a.entry.overlapBlockedTodoCount) return b.entry.overlapBlockedTodoCount - a.entry.overlapBlockedTodoCount; const aAge = a.entry.escalation?.blockingAgeMs ?? 0; const bAge = b.entry.escalation?.blockingAgeMs ?? 0; if (bAge !== aAge) return bAge - aAge; return a.blockerId.localeCompare(b.blockerId, "en", { numeric: true, sensitivity: "base" }); }); return candidates[0] ?? null; }, [tasks, staleHighFanoutBlockerAgeThresholdMs]); const StateIcon = stateDisplay.icon; if (error) { return (