FN-6933: show stopped engine state in footer

Show the dashboard footer when the global AI engine stop control is active.

- Add a stopped executor state derived from globalPause and expose it through the legacy API types.
- Render Stopped with error-red styling and a stop-rectangle icon in the executor status bar.
- Cover stopped state derivation, footer rendering, API responses, and localized labels.

Files changed:
 .../dashboard/app/__tests__/api-projects.test.ts   |  3 ++-
 packages/dashboard/app/api/legacy.ts               | 14 +++++++----
 .../dashboard/app/components/ExecutorStatusBar.tsx | 11 ++++++---
 .../__tests__/ExecutorStatusBar.test.tsx           | 27 ++++++++++++++++++++++
 .../app/hooks/__tests__/useExecutorStats.test.ts   | 22 ++++++++++++++++--
 packages/dashboard/app/hooks/useExecutorStats.ts   | 10 +++++---
 packages/i18n/locales/en/app.json                  |  1 +
 packages/i18n/locales/es/app.json                  |  2 ++
 packages/i18n/locales/fr/app.json                  |  2 ++
 packages/i18n/locales/ko/app.json                  |  2 ++
 packages/i18n/locales/zh-CN/app.json               |  2 ++
 packages/i18n/locales/zh-TW/app.json               |  2 ++
 12 files changed, 85 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-6933

Fusion-Task-Lineage: 1f128b36-105a-4133-87ce-75bb8df8a42f
This commit is contained in:
gsxdsm
2026-06-23 00:45:00 -07:00
parent 5e55d9c46c
commit 90cba95bdd
12 changed files with 85 additions and 13 deletions

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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(<ExecutorStatusBar tasks={emptyTasks} />);
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(<ExecutorStatusBar tasks={emptyTasks} />);
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(<ExecutorStatusBar tasks={emptyTasks} />);

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -2316,6 +2316,7 @@
"stateIdle": "Idle",
"statePaused": "Paused",
"stateRunning": "Running",
"stateStopped": "Stopped",
"status": "Executor status",
"stuck": "Stuck",
"temporary": "Temporary",

View File

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

View File

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

View File

@@ -2313,6 +2313,7 @@
"stateIdle": "유휴",
"statePaused": "일시 중지됨",
"stateRunning": "실행 중",
"stateStopped": "중지됨",
"status": "실행기 상태",
"stuck": "중단됨",
"temporary": "임시",
@@ -6609,6 +6610,7 @@
"firstAgentDraftName": "",
"firstAgentContinueWithTemplates": "",
"firstAgentInterviewLoadError": "",
"firstAgentInterviewLoading": "",
"firstAgentIntro": "",
"firstAgentNoInstructions": "",
"firstAgentPreview": "",

View File

@@ -2313,6 +2313,7 @@
"stateIdle": "空闲",
"statePaused": "已暂停",
"stateRunning": "运行中",
"stateStopped": "已停止",
"status": "执行器状态",
"stuck": "卡顿",
"temporary": "临时",
@@ -6609,6 +6610,7 @@
"firstAgentDraftName": "",
"firstAgentContinueWithTemplates": "",
"firstAgentInterviewLoadError": "",
"firstAgentInterviewLoading": "",
"firstAgentIntro": "",
"firstAgentNoInstructions": "",
"firstAgentPreview": "",

View File

@@ -2313,6 +2313,7 @@
"stateIdle": "閒置",
"statePaused": "已暫停",
"stateRunning": "執行中",
"stateStopped": "已停止",
"status": "執行器狀態",
"stuck": "卡住",
"temporary": "暫時",
@@ -6609,6 +6610,7 @@
"firstAgentDraftName": "",
"firstAgentContinueWithTemplates": "",
"firstAgentInterviewLoadError": "",
"firstAgentInterviewLoading": "",
"firstAgentIntro": "",
"firstAgentNoInstructions": "",
"firstAgentPreview": "",