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": "",