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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2316,6 +2316,7 @@
|
||||
"stateIdle": "Idle",
|
||||
"statePaused": "Paused",
|
||||
"stateRunning": "Running",
|
||||
"stateStopped": "Stopped",
|
||||
"status": "Executor status",
|
||||
"stuck": "Stuck",
|
||||
"temporary": "Temporary",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -2313,6 +2313,7 @@
|
||||
"stateIdle": "유휴",
|
||||
"statePaused": "일시 중지됨",
|
||||
"stateRunning": "실행 중",
|
||||
"stateStopped": "중지됨",
|
||||
"status": "실행기 상태",
|
||||
"stuck": "중단됨",
|
||||
"temporary": "임시",
|
||||
@@ -6609,6 +6610,7 @@
|
||||
"firstAgentDraftName": "",
|
||||
"firstAgentContinueWithTemplates": "",
|
||||
"firstAgentInterviewLoadError": "",
|
||||
"firstAgentInterviewLoading": "",
|
||||
"firstAgentIntro": "",
|
||||
"firstAgentNoInstructions": "",
|
||||
"firstAgentPreview": "",
|
||||
|
||||
@@ -2313,6 +2313,7 @@
|
||||
"stateIdle": "空闲",
|
||||
"statePaused": "已暂停",
|
||||
"stateRunning": "运行中",
|
||||
"stateStopped": "已停止",
|
||||
"status": "执行器状态",
|
||||
"stuck": "卡顿",
|
||||
"temporary": "临时",
|
||||
@@ -6609,6 +6610,7 @@
|
||||
"firstAgentDraftName": "",
|
||||
"firstAgentContinueWithTemplates": "",
|
||||
"firstAgentInterviewLoadError": "",
|
||||
"firstAgentInterviewLoading": "",
|
||||
"firstAgentIntro": "",
|
||||
"firstAgentNoInstructions": "",
|
||||
"firstAgentPreview": "",
|
||||
|
||||
@@ -2313,6 +2313,7 @@
|
||||
"stateIdle": "閒置",
|
||||
"statePaused": "已暫停",
|
||||
"stateRunning": "執行中",
|
||||
"stateStopped": "已停止",
|
||||
"status": "執行器狀態",
|
||||
"stuck": "卡住",
|
||||
"temporary": "暫時",
|
||||
@@ -6609,6 +6610,7 @@
|
||||
"firstAgentDraftName": "",
|
||||
"firstAgentContinueWithTemplates": "",
|
||||
"firstAgentInterviewLoadError": "",
|
||||
"firstAgentInterviewLoading": "",
|
||||
"firstAgentIntro": "",
|
||||
"firstAgentNoInstructions": "",
|
||||
"firstAgentPreview": "",
|
||||
|
||||
Reference in New Issue
Block a user