import "./ExecutorStatusBar.css"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, type Task, } from "@fusion/core"; import { AlertTriangle, Clock, Folder, MessageSquare, Pause, Play, Square, Zap } from "lucide-react"; import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout"; import { useExecutorStats, type ExecutorColumnFlags } from "../hooks/useExecutorStats"; import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension"; import { LoadingSpinner } from "./LoadingSpinner"; import type { ExecutorState } from "../api"; import { EngineControlMenu, type EngineControlMenuHandle } from "./EngineControlMenu"; import { TerminalLauncher } from "./TerminalLauncher"; import { useViewportMode } from "../hooks/useViewportMode"; /* FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side. The footer no longer renders a Stuck segment or takes taskStuckTimeoutMs. */ type FooterStatId = "queued" | "running" | "blocked" | "fanout"; interface OpenStatTooltip { id: FooterStatId; label: string; rect: DOMRect; } interface MobileStatSegmentProps { className?: string; id: FooterStatId; isMobile: boolean; isOpen: boolean; label: string; onToggle: (id: FooterStatId, label: string, rect: DOMRect) => void; children: ReactNode; } function MobileStatSegment({ className = "", id, isMobile, isOpen, label, onToggle, children }: MobileStatSegmentProps) { const segmentClassName = `executor-status-bar__segment executor-status-bar__segment--stat ${className}`.trim(); if (!isMobile) return
{children}
; const tooltipId = `executor-status-bar-tooltip-${id}`; return ( ); } interface ExecutorStatusBarProps { /** Task list (shared with the board to keep counts in sync) */ tasks: Task[]; /** Project ID for fetching project-specific stats */ projectId?: string; /** Optional task-scoped board trait index supplied by an embedding board. */ columnFlagsByTaskId?: ReadonlyMap; /** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */ staleHighFanoutBlockerAgeThresholdMs?: number; /** Timestamp (ms) when task data was last confirmed fresh from the server. */ 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; /** Opens or closes the terminal surface from the desktop/tablet footer launcher. */ onToggleTerminal?: () => void; /** Opens the scripts management modal from the footer launcher dropdown. */ onOpenScripts?: () => void; /** Runs a configured script in the terminal from the footer launcher dropdown. */ onRunScript?: (name: string, command: string) => void; /** Quick Chat launcher placement from Settings. */ quickChatButtonMode?: "floating" | "footer" | "off"; /** Opens the full Chat modal from the footer launcher. */ onOpenQuickChat?: () => void; } /** * Format a relative time string (e.g., "2m ago", "1h ago") */ function formatRelativeTime(timestamp: string | undefined, t: TFunction<"app">): string { if (!timestamp) return t("executor.noActivity", "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 t("executor.daysAgo", "{{count}}d ago", { count: days }); if (hours > 0) return t("executor.hoursAgo", "{{count}}h ago", { count: hours }); if (minutes > 0) return t("executor.minutesAgo", "{{count}}m ago", { count: minutes }); if (seconds > 10) return t("executor.secondsAgo", "{{count}}s ago", { count: seconds }); return t("executor.justNow", "just now"); } /** * Get display configuration for an executor state. * * FNXC:EngineControls 2026-06-22-00:00: * A stopped engine must use the same stop-rectangle affordance as the engine-control menu and error-red status text so operators do not confuse it with idle capacity. */ function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: string; color: string; icon: typeof Play } { switch (state) { case "running": return { label: t("executor.stateRunning", "Running"), color: "var(--color-success)", icon: Play }; case "paused": return { label: t("executor.statePaused", "Paused"), color: "var(--triage)", icon: Pause }; case "stopped": return { label: t("executor.stateStopped", "Stopped"), color: "var(--color-error)", icon: Square }; case "idle": default: return { label: t("executor.stateIdle", "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/stopped) * - Last activity timestamp */ export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppliedColumnFlagsByTaskId, staleHighFanoutBlockerAgeThresholdMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript, quickChatButtonMode = "off", onOpenQuickChat }: ExecutorStatusBarProps) { const { t } = useTranslation("app"); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const showTerminalLauncher = !isMobile && Boolean(onToggleTerminal); const columnFlagsByTaskId = suppliedColumnFlagsByTaskId; /* FNXC:ConcurrencyIndicators 2026-07-21-12:00: FN-8453 receives a task-scoped board trait index from App before the shared live-agent predicate runs. Literal column ids are only a loading/legacy fallback. */ /* * FNXC:ChatLauncher 2026-06-22-15:18: * Settings can route Quick Chat to a footer launcher beside Terminal, keep the draggable floating FAB, or hide the launcher entirely. Footer launch stays desktop/tablet-only like Terminal while mobile opens from the floating path as a full-screen modal. */ const showQuickChatFooterLauncher = !isMobile && quickChatButtonMode === "footer" && Boolean(onOpenQuickChat); const { stats, loading, error } = useExecutorStats(tasks, projectId, columnFlagsByTaskId); const [isProjectPathVisible, setIsProjectPathVisible] = useState(false); const [openStatTooltip, setOpenStatTooltip] = useState(null); const engineControlMenuRef = useRef(null); const hasRenderedPopulatedStatsRef = useRef(false); useEffect(() => { if (!loading && !error) { hasRenderedPopulatedStatsRef.current = true; } }, [error, loading]); /* * FNXC:MobileFooter 2026-07-16-00:00: * Mobile hides footer stat labels, so tapping a stat must reveal its name. The * executor-status-bar--mobile class and this interaction share isMobile so * short-landscape phones cannot expose both labels and tap controls. The * popover is portaled below to escape the footer's overflow clipping. */ useEffect(() => { if (!isMobile || !openStatTooltip) return; const dismissTooltip = () => setOpenStatTooltip(null); const dismissOnPointerDown = (event: PointerEvent) => { if (event.target instanceof Element && event.target.closest(".executor-status-bar__segment--stat")) return; dismissTooltip(); }; const dismissOnFocus = (event: FocusEvent) => { if (event.target instanceof Element && event.target.closest(".executor-status-bar__segment--stat")) return; dismissTooltip(); }; const dismissOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") dismissTooltip(); }; document.addEventListener("pointerdown", dismissOnPointerDown); document.addEventListener("focusin", dismissOnFocus); document.addEventListener("keydown", dismissOnEscape); window.addEventListener("scroll", dismissTooltip, true); return () => { document.removeEventListener("pointerdown", dismissOnPointerDown); document.removeEventListener("focusin", dismissOnFocus); document.removeEventListener("keydown", dismissOnEscape); window.removeEventListener("scroll", dismissTooltip, true); }; }, [isMobile, openStatTooltip]); useEffect(() => { if (!isMobile) setOpenStatTooltip(null); }, [isMobile]); const toggleStatTooltip = (id: FooterStatId, label: string, rect: DOMRect) => { setOpenStatTooltip((current) => current?.id === id ? null : { id, label, rect }); }; const stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]); const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt, t), [stats.lastActivityAt, t]); const highestOverlapBlocker = useMemo(() => { const fanoutMap = computeBlockerFanoutMap(tasks, { staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs ?? STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, /* FNXC:WorkflowResolvedColumns 2026-07-30-23:55: The same per-task trait index this bar already takes for its running counts. Without it the fan-out classified against `todo`/`in-review`/`done`, so on a renamed board the overlap bottleneck this segment exists to surface was never detected at all. */ columnFlagsByTaskId, }); 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, columnFlagsByTaskId]); const StateIcon = stateDisplay.icon; // Keyboard-open guard runs after all hooks: toggling it must not change the // hook count between renders (Rules of Hooks). if (hideWhenKeyboardOpen) return null; if (error) { if (isLikelyTabSuspensionError(error)) { return (
); } return (
{error}
); } /* * FNXC:ExecutorStatusBar 2026-06-27-00:00: * The loading subtree is initial-load-only. Once the populated footer has mounted, a heartbeat poll must keep this branch suppressed so EngineControlMenu stays mounted and an open concurrency popover survives routine stats refreshes (FN-7163). */ if (loading && stats.runningTaskCount === 0 && !hasRenderedPopulatedStatsRef.current) { return (
); } return (
{/* * FNXC:ExecutorStatusBar 2026-07-16-20:55: * FN-8229 removes the redundant AI-session footer segment. The banner and * Planning navigation now own session visibility, leaving this footer for * executor statistics and launch controls only. */} {/* Waiting intake/hold tasks */} {/* Separator */}