diff --git a/packages/dashboard/app/__tests__/api-projects.test.ts b/packages/dashboard/app/__tests__/api-projects.test.ts index b3a5e51ec8..7d8be6c205 100644 --- a/packages/dashboard/app/__tests__/api-projects.test.ts +++ b/packages/dashboard/app/__tests__/api-projects.test.ts @@ -1169,11 +1169,12 @@ describe("ExecutorStats type", () => { describe("ExecutorState type", () => { it("has valid executor state values", () => { - const states: ExecutorState[] = ["idle", "running", "paused"]; + const states: ExecutorState[] = ["idle", "running", "paused", "stopped"]; expect(states).toContain("idle"); expect(states).toContain("running"); expect(states).toContain("paused"); + expect(states).toContain("stopped"); }); }); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 7f64ec86ec..b4dfa9d33a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6709,8 +6709,13 @@ export interface ProjectHealth { updatedAt: string; } -/** Executor state values */ -export type ExecutorState = "idle" | "running" | "paused"; +/** + * Executor state values. + * + * FNXC:EngineControls 2026-06-22-00:00: + * A globally stopped AI engine (`globalPause`) is an operator action, not idleness; the footer must expose it as "Stopped" in error red with the stop-rectangle icon. + */ +export type ExecutorState = "idle" | "running" | "paused" | "stopped"; /** Aggregated executor statistics for the status bar. * @@ -6721,7 +6726,8 @@ export type ExecutorState = "idle" | "running" | "paused"; * lastActivityAt from the activity log. * * The executorState is derived from: - * - "idle": globalPause is true OR (enginePaused is true AND runningTaskCount is 0) + * - "stopped": globalPause is true + * - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running * - "paused": enginePaused is true AND runningTaskCount > 0 * - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0 */ @@ -6736,7 +6742,7 @@ export interface ExecutorStats { queuedTaskCount: number; /** Number of tasks in "in-review" column */ inReviewCount: number; - /** Derived executor state: "idle", "running", or "paused" */ + /** Derived executor state: "idle", "running", "paused", or "stopped" */ executorState: ExecutorState; /** Maximum concurrent tasks allowed from settings */ maxConcurrent: number; diff --git a/packages/dashboard/app/components/ExecutorStatusBar.tsx b/packages/dashboard/app/components/ExecutorStatusBar.tsx index 5273368f58..c089766222 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.tsx +++ b/packages/dashboard/app/components/ExecutorStatusBar.tsx @@ -7,7 +7,7 @@ import { STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, type Task, } from "@fusion/core"; -import { AlertTriangle, Clock, Folder, MessageSquare, Pause, Play, Zap } from "lucide-react"; +import { AlertTriangle, Clock, Folder, MessageSquare, Pause, Play, Square, Zap } from "lucide-react"; import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout"; import { useExecutorStats } from "../hooks/useExecutorStats"; import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension"; @@ -80,7 +80,10 @@ function formatRelativeTime(timestamp: string | undefined, t: TFunction<"app">): } /** - * Get display configuration for an executor state + * 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) { @@ -88,6 +91,8 @@ function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: st 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 }; @@ -101,7 +106,7 @@ function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: st * - 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) + * - Executor state badge (idle/running/paused/stopped) * - Last activity timestamp */ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript, quickChatButtonMode = "off", onOpenQuickChat }: ExecutorStatusBarProps) { diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx index a32ea8b831..bac2c612b3 100644 --- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx @@ -368,6 +368,33 @@ describe("ExecutorStatusBar", () => { expect(stateElement).toHaveTextContent("Idle"); }); + it("shows Stopped state in error color without running class on desktop and mobile", () => { + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: { ...defaultStats, executorState: "stopped", runningTaskCount: 0 }, + loading: false, + error: null, + refresh: vi.fn(), + }); + + const { rerender } = render(); + + const desktopStatusBar = screen.getByRole("status"); + const desktopStateElement = desktopStatusBar.querySelector(".executor-status-bar__state"); + const desktopStateIcon = screen.getByTestId("executor-state-engine-control-trigger").querySelector("svg"); + expect(desktopStateElement).toHaveTextContent("Stopped"); + expect(desktopStateElement).toHaveStyle({ color: "var(--color-error)" }); + expect(desktopStateIcon).toHaveStyle({ color: "var(--color-error)" }); + expect(desktopStatusBar).not.toHaveClass("executor-status-bar--running"); + + viewportModeMock.value = "mobile"; + rerender(); + + const mobileStatusBar = screen.getByRole("status"); + const mobileStateElement = mobileStatusBar.querySelector(".executor-status-bar__state"); + expect(mobileStateElement).toHaveTextContent("Stopped"); + expect(mobileStatusBar).not.toHaveClass("executor-status-bar--running"); + }); + it("applies running class when executor is running", () => { render(); diff --git a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts index 350ec4d695..f74fbea1b8 100644 --- a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts @@ -264,7 +264,7 @@ describe("useExecutorStats", () => { }); describe("executor state derivation", () => { - it("returns 'idle' when globalPause is true", async () => { + it("returns 'stopped' when globalPause is true", async () => { mockFetchExecutorStats.mockResolvedValue({ globalPause: true, enginePaused: false, @@ -277,7 +277,25 @@ describe("useExecutorStats", () => { await vi.advanceTimersByTimeAsync(100); }); - expect(result.current.stats.executorState).toBe("idle"); + expect(result.current.stats.executorState).toBe("stopped"); + }); + + it("returns 'stopped' when globalPause is true even with running tasks", async () => { + const tasks: Task[] = [createMockTask("FN-001", "in-progress")]; + mockFetchExecutorStats.mockResolvedValue({ + globalPause: true, + enginePaused: false, + maxConcurrent: 4, + }); + + const { result } = renderHook(() => useExecutorStats(tasks)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + expect(result.current.stats.runningTaskCount).toBe(1); + expect(result.current.stats.executorState).toBe("stopped"); }); it("returns 'idle' when enginePaused is true and runningTaskCount is 0", async () => { diff --git a/packages/dashboard/app/hooks/useExecutorStats.ts b/packages/dashboard/app/hooks/useExecutorStats.ts index 2a0d0873db..089623c430 100644 --- a/packages/dashboard/app/hooks/useExecutorStats.ts +++ b/packages/dashboard/app/hooks/useExecutorStats.ts @@ -21,9 +21,13 @@ export interface UseExecutorStatsResult { /** * Derive the executor state from globalPause, enginePaused, and runningTaskCount. * - * - "idle": globalPause is true OR (enginePaused is true AND runningTaskCount is 0) + * - "stopped": globalPause is true + * - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running * - "paused": enginePaused is true AND runningTaskCount > 0 * - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0 + * + * FNXC:EngineControls 2026-06-22-00:00: + * `globalPause` dominates the footer state matrix so an operator-stopped engine is distinct from idle even if in-progress tasks still exist. */ function deriveExecutorState( globalPause: boolean, @@ -31,7 +35,7 @@ function deriveExecutorState( runningTaskCount: number ): ExecutorState { if (globalPause) { - return "idle"; + return "stopped"; } if (enginePaused && runningTaskCount === 0) { return "idle"; @@ -99,7 +103,7 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFe * - Derives blockedTaskCount from tasks with blockedBy field set * - Derives stuckTaskCount using the project's `taskStuckTimeoutMs` setting; * returns 0 when the setting is undefined/disabled - * - Derives executorState from globalPause and enginePaused flags + * - Derives executorState from globalPause and enginePaused flags, with globalPause mapping to "stopped" * - Returns ExecutorStats object with reactive updates */ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): UseExecutorStatsResult { diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index b090ba2978..f7cff4a50b 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -2316,6 +2316,7 @@ "stateIdle": "Idle", "statePaused": "Paused", "stateRunning": "Running", + "stateStopped": "Stopped", "status": "Executor status", "stuck": "Stuck", "temporary": "Temporary", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 6199350501..16c2c188c5 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -2313,6 +2313,7 @@ "stateIdle": "Inactivo", "statePaused": "En pausa", "stateRunning": "Ejecutando", + "stateStopped": "Detenido", "status": "Estado del ejecutor", "stuck": "Atascado", "temporary": "Temporal", @@ -6609,6 +6610,7 @@ "firstAgentDraftName": "", "firstAgentContinueWithTemplates": "", "firstAgentInterviewLoadError": "", + "firstAgentInterviewLoading": "", "firstAgentIntro": "", "firstAgentNoInstructions": "", "firstAgentPreview": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index bcbed2ee46..ed6973fddd 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -2313,6 +2313,7 @@ "stateIdle": "Inactif", "statePaused": "En pause", "stateRunning": "En cours d'exécution", + "stateStopped": "Arrêté", "status": "État de l'exécuteur", "stuck": "Bloqué", "temporary": "Temporaire", @@ -6609,6 +6610,7 @@ "firstAgentDraftName": "", "firstAgentContinueWithTemplates": "", "firstAgentInterviewLoadError": "", + "firstAgentInterviewLoading": "", "firstAgentIntro": "", "firstAgentNoInstructions": "", "firstAgentPreview": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 9a4fa44d0c..de01a82ff5 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -2313,6 +2313,7 @@ "stateIdle": "유휴", "statePaused": "일시 중지됨", "stateRunning": "실행 중", + "stateStopped": "중지됨", "status": "실행기 상태", "stuck": "중단됨", "temporary": "임시", @@ -6609,6 +6610,7 @@ "firstAgentDraftName": "", "firstAgentContinueWithTemplates": "", "firstAgentInterviewLoadError": "", + "firstAgentInterviewLoading": "", "firstAgentIntro": "", "firstAgentNoInstructions": "", "firstAgentPreview": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 83271bcaa9..785e1516f0 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -2313,6 +2313,7 @@ "stateIdle": "空闲", "statePaused": "已暂停", "stateRunning": "运行中", + "stateStopped": "已停止", "status": "执行器状态", "stuck": "卡顿", "temporary": "临时", @@ -6609,6 +6610,7 @@ "firstAgentDraftName": "", "firstAgentContinueWithTemplates": "", "firstAgentInterviewLoadError": "", + "firstAgentInterviewLoading": "", "firstAgentIntro": "", "firstAgentNoInstructions": "", "firstAgentPreview": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 4aeaeef511..71c98e86d3 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -2313,6 +2313,7 @@ "stateIdle": "閒置", "statePaused": "已暫停", "stateRunning": "執行中", + "stateStopped": "已停止", "status": "執行器狀態", "stuck": "卡住", "temporary": "暫時", @@ -6609,6 +6610,7 @@ "firstAgentDraftName": "", "firstAgentContinueWithTemplates": "", "firstAgentInterviewLoadError": "", + "firstAgentInterviewLoading": "", "firstAgentIntro": "", "firstAgentNoInstructions": "", "firstAgentPreview": "",