Files
fusion/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx
gsxdsm cdf67c1d98 fix(dashboard): stop Planning Mode retry loop, make AI sessions multi-tab (#2101)
## Problem

Reported: planning gets stuck in a cycle of retrying and regenerating
after a response was already supplied.

After the user answers a planning question, `submitResponse` pushed the
answer to history but left `session.currentQuestion` pointing at the
just-answered question for the whole next generation. The planning SSE
route's catch-up path re-emits `currentQuestion` to every fresh
connection — and each FN-7946 auto-retry (#2073) opens a fresh
connection. So after any generation error:

1. Auto-retry connects a fresh stream → the server re-emits the
**already-answered** question.
2. The client treats any question event as progress: it **resets the
3-attempt auto-retry budget** and re-shows the answered question.
3. The retry regenerates; if it errors again the cycle repeats with a
fresh budget — an unbounded retry/regenerate loop. Re-answering the
stale question also 409-collided with the in-flight generation, feeding
the same loop.

## Fix

Invariant: `currentQuestion` is only set while the session is genuinely
awaiting user input.

- `submitResponse` clears it the moment an answer is accepted (normal
turns and the deepening checkpoint), while preserving the legacy 200
respond contract on generation failure (the modal ignores the body and
lets the SSE error drive recovery).
- `retrySession` scrubs stale questions persisted by pre-fix builds
before regenerating.
- `buildSessionFromRow` only restores a question when the persisted row
is `awaiting_input`.
- `didSubmitSameAnswer` now compares against the last history entry so
the duplicate-submit 409 message survives.
- Agent onboarding gets the same fix (its SSE route also re-emits
`currentQuestion` on connect); retry now asks the next question instead
of re-asking the answered one.

Surface enumeration: mission and milestone interviews keep questions the
same way but their SSE routes never re-emit on connect, and the
auto-retry budget machinery is Planning-Mode-only — planning +
onboarding were the two affected surfaces.

## Symptom Verification

- **Original symptom:** after answering a question, Planning Mode loops
between "Retrying…" and regenerating, re-showing the already-answered
question, with the auto-retry budget never exhausting.
- **Exact reproduction:** answer a question, have the next generation
fail (stuck watchdog/provider error), let the client auto-retry open a
fresh SSE connection.
- **Assertion it is gone:** new regression suite
`planning-answered-question-reemit.test.ts` asserts `currentQuestion` is
cleared mid-generation, on generation failure, on retry, and on restore
from non-`awaiting_input` rows — so the SSE catch-up path has nothing
stale to re-emit. All 5 tests fail against pre-fix code and pass with
the fix; an onboarding regression test covers the sibling surface.

## Verification

- New regression tests: 5/5 fail on pre-fix code, pass with the fix
(plus 1 onboarding test).
- Existing suites: 137 planning server tests pass (3 failures in
`routes-planning.test.ts` fail identically without this change —
pre-existing on the branch); all 69 `PlanningModeModal.planning-flow`
client tests pass; `tsc --noEmit` clean; `pnpm check:changesets` passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Made Planning Mode (and related planning controls) lock-free and
multi-tab—no more take-over/active-in-another-tab lock overlays.

* **Bug Fixes**
* Fixed Planning Mode retry/generation flows where already-answered
questions could reappear.
* Ensured answered questions clear immediately and aren’t re-emitted
during session recovery/SSE catch-up.
* Improved session restoration and preserved legacy recovery behavior
when generation fails after an answer.

* **Tests**
* Added regression coverage for the answered-question invariant and
updated existing tests to reflect lock-free behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Follow-up: Planning Mode is now multi-tab via DB state (lock-free)

Second commit removes all cross-tab coordination from planning — the
persisted session row is the single source of truth and multiple tabs
can read and interact with the same session:

- **Server:** `/planning/*` routes no longer run `checkSessionLock` or
parse `tabId`; a stale `tabId` from an older client is ignored instead
of 409'd. Subtask/mission interview routes keep their existing lock
behavior.
- **Client:** `PlanningModeModal` drops `useSessionLock`, the
`useAiSessionSync` BroadcastChannel broadcasts,
`sessionTabId`/`lockSessionId` state, and the "Take Control" overlay.
Tabs stay current via the per-session SSE stream plus the global
`ai_session:updated` events `useBackgroundSessions` already consumes;
concurrent writes resolve via the server's generation-in-progress guard
(409).
- **API client:** planning functions lose their `tabId` params.
- **Fix uncovered by the refactor:** the 8s stuck-poll now resolves the
session id inside each tick — the removed lock state was what previously
re-armed the poll after Start Planning resolved the session id.
- Also fixes a pre-existing PG-cutover break in
`planning-generation-cancellation.test.ts` (`getSession` is async).

Verification: 144 client planning tests and 137 server planning tests
pass (the 3 remaining `routes-planning.test.ts` failures are
pre-existing on the branch and fail identically without these changes);
`tsc --noEmit` and eslint clean on changed files; `pnpm
check:changesets` passes. Lock-conflict route tests were rewritten to
assert lock-free semantics, plus a new modal test proving a session
stays fully interactive with no lock acquisition even when another tab
is active.


---

## Follow-up 2: the per-tab session lock is gone entirely

Third commit extends the multi-tab model from planning to **every** AI
interview surface (planning, subtask breakdown, mission interview,
milestone/slice interview) and deletes the lock machinery root and
branch.

**Server**
- Deleted the `/ai-sessions/:id/lock`, `/lock/force`, and `/lock/beacon`
routes.
- Dropped `checkSessionLock` from every
planning/subtask/mission/milestone route (both copies — `routes.ts` and
`mission-routes.ts`). A `tabId` from an older client is ignored, never
409'd; all `tabId` body parsing is gone.
- Dropped `acquireLock` / `releaseLock` / `forceAcquireLock` /
`getLockHolder` / `releaseStaleLocks` from `AiSessionStore`, plus the
`@fusion/core` async helpers (`acquireAiSessionLock` et al) and core's
re-exports.
- Removed `lockedByTab`/`lockedAt` from
`AiSessionRow`/`AiSessionSummary`, the upsert SQL, and all four session
producers.

**Client**
- Deleted `useSessionLock` and the now-orphaned `getSessionTabId` util.
- Removed the Take Control overlay, the "active in another tab" banners,
and `BackgroundTasksIndicator`'s active-elsewhere gate (the confirm
prompt and lock badge — sessions now just open).
- Reduced `useAiSessionSync` to what its own comments already called it
— a low-latency *status* supplement to SSE: no `activeTabMap`,
`broadcastLock/Unlock/Heartbeat`, `owningTabId`, `tab:*` messages, or
stale-heartbeat sweep.
- Dropped `tabId` from every session API client function; removed the
lock CSS.

**Deliberately kept: the two DB columns.** `ai_sessions.locked_by_tab` /
`locked_at` remain as dead, always-NULL columns with a deprecation note.
Dropping them is an irreversible migration, and released binaries still
name those columns explicitly in their upsert — an older install pointed
at the same database would fail every session write. They can be dropped
once no such binary can reach it. No code reads or writes them.

**Verification**: 397 client tests and 137 server planning tests pass
(the same 3 `routes-planning.test.ts` failures are pre-existing —
verified identical on a clean stash); `tsc --noEmit` clean for
`@fusion/core` and `@fusion/dashboard`; eslint clean on all changed
files; the 30 PG `schema-applier` tests pass (they exercise the retained
columns); `pnpm check:changesets` passes. The lock-conflict route tests
and both modal lock tests were rewritten to assert the inverse: routes
and modals stay fully interactive while another tab "holds" a lock, and
the lock API is never called.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:47:53 -07:00

1027 lines
39 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import fs from "fs";
import path from "path";
import { ExecutorStatusBar } from "../ExecutorStatusBar";
const viewportModeMock = vi.hoisted(() => ({ value: "desktop" as "desktop" | "tablet" | "mobile" }));
const mockFetchScripts = vi.hoisted(() => vi.fn());
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => viewportModeMock.value,
}));
vi.mock("../../api", () => ({
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
// Mock the useExecutorStats hook
vi.mock("../../hooks/useExecutorStats", () => ({
useExecutorStats: vi.fn(),
}));
vi.mock("../EngineControlMenu", async () => {
const React = await import("react");
return {
EngineControlMenu: React.forwardRef(function MockEngineControlMenu(_props: unknown, ref) {
const [open, setOpen] = React.useState(false);
React.useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
toggle: () => setOpen((current) => !current),
}));
return (
<div>
<button type="button" data-testid="engine-control-menu-trigger" onClick={() => setOpen((current) => !current)}>Engine controls</button>
{open ? <div role="menu" data-testid="engine-control-menu">Engine menu</div> : null}
</div>
);
}),
};
});
import { useExecutorStats } from "../../hooks/useExecutorStats";
import type { AiSessionSummary, ExecutorStats } from "../../api";
const mockUseExecutorStats = useExecutorStats as ReturnType<typeof vi.fn>;
const executorStatusBarCss = fs.readFileSync(path.join(__dirname, "../ExecutorStatusBar.css"), "utf-8");
const terminalLauncherCss = fs.readFileSync(path.join(__dirname, "../TerminalLauncher.css"), "utf-8");
function getCssRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
return match?.[1] ?? "";
}
function getCssRuleBlockByPattern(css: string, pattern: RegExp): string {
const match = css.match(pattern);
return match?.[1] ?? "";
}
function expectNoHardcodedColors(cssBlock: string): void {
expect(cssBlock).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(/i);
}
/** Minimal empty task list used by tests that mock the hook. */
const emptyTasks: any[] = [];
function makeTask(id: string, column: string, overrides: Record<string, unknown> = {}) {
return {
id,
description: `Task ${id}`,
column,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function getSegmentByLabel(label: string): HTMLElement {
const labelElement = screen
.getAllByText(label)
.find((element) => element.classList.contains("executor-status-bar__label"));
expect(labelElement).toBeTruthy();
const segment = labelElement?.closest(".executor-status-bar__segment");
expect(segment).toBeTruthy();
return segment as HTMLElement;
}
function expectSegmentCount(label: string, count: string): void {
expect(within(getSegmentByLabel(label)).getByText(count)).toBeInTheDocument();
}
function makeBackgroundSession(id: string, status: AiSessionSummary["status"]): AiSessionSummary {
return {
id,
type: "planning",
status,
title: `Background ${id}`,
projectId: "project-1",
updatedAt: "2026-07-03T12:00:00.000Z",
};
}
describe("ExecutorStatusBar", () => {
const defaultStats: ExecutorStats = {
runningTaskCount: 2,
blockedTaskCount: 1,
stuckTaskCount: 0,
queuedTaskCount: 5,
inReviewCount: 3,
executorState: "running",
maxConcurrent: 4,
lastActivityAt: new Date().toISOString(),
};
beforeEach(() => {
vi.clearAllMocks();
viewportModeMock.value = "desktop";
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
error: null,
refresh: vi.fn(),
});
});
afterEach(() => {
vi.useRealTimers();
});
describe("rendering", () => {
it("renders all stat segments", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Running");
expect(statusBar).toHaveTextContent("Blocked");
expect(statusBar).toHaveTextContent("Queued");
expect(statusBar).toHaveTextContent("In Review");
expect(statusBar).not.toHaveTextContent("Done");
expect(statusBar).not.toHaveTextContent("Escalated");
});
it.each(["desktop", "tablet"] as const)("associates every visible footer count with its label on %s", (viewportMode) => {
viewportModeMock.value = viewportMode;
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: {
...defaultStats,
queuedTaskCount: 9,
runningTaskCount: 2,
maxConcurrent: 4,
stuckTaskCount: 1,
blockedTaskCount: 2,
inReviewCount: 1,
},
loading: false,
error: null,
refresh: vi.fn(),
});
const tasks = [
makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
makeTask("FN-101", "todo", { blockedBy: "FN-010" }),
makeTask("FN-102", "todo", { blockedBy: "FN-010" }),
makeTask("FN-103", "todo", { blockedBy: "FN-010" }),
makeTask("FN-104", "todo", { blockedBy: "FN-010" }),
makeTask("FN-105", "todo", { blockedBy: "FN-010" }),
];
render(
<ExecutorStatusBar
tasks={tasks as any[]}
staleHighFanoutBlockerAgeThresholdMs={60 * 60 * 1000}
backgroundSessions={[
makeBackgroundSession("ai-1", "generating"),
makeBackgroundSession("ai-2", "awaiting_input"),
]}
backgroundGenerating={1}
backgroundNeedsInput={1}
onOpenBackgroundSession={vi.fn()}
onDismissBackgroundSession={vi.fn()}
/>,
);
const statusBar = screen.getByRole("status");
expectSegmentCount("Queued", "9");
expectSegmentCount("Running", "2");
expect(within(getSegmentByLabel("Running")).getByText("4")).toHaveClass("executor-status-bar__max");
expectSegmentCount("Stuck", "1");
expectSegmentCount("Blocked", "2");
expectSegmentCount("In Review", "1");
expect(statusBar).toHaveTextContent("Overlap queue");
expect(statusBar).toHaveTextContent("FN-010 · 5 todo");
expect(statusBar).toHaveTextContent("AI 2");
expect(statusBar).not.toHaveTextContent("Done");
expect(statusBar.firstElementChild).not.toHaveClass("executor-status-bar__divider");
expect(statusBar.lastElementChild).toHaveClass("executor-status-bar__segment--engine-controls");
});
it("shows overlap bottleneck summary with stable tie-break ordering", () => {
const tasks = [
makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
makeTask("FN-002", "in-review", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
makeTask("FN-101", "todo", { blockedBy: "FN-010" }),
makeTask("FN-102", "todo", { blockedBy: "FN-010" }),
makeTask("FN-103", "todo", { blockedBy: "FN-010" }),
makeTask("FN-104", "todo", { blockedBy: "FN-010" }),
makeTask("FN-105", "todo", { blockedBy: "FN-010" }),
makeTask("FN-201", "todo", { blockedBy: "FN-002" }),
makeTask("FN-202", "todo", { blockedBy: "FN-002" }),
makeTask("FN-203", "todo", { blockedBy: "FN-002" }),
makeTask("FN-204", "todo", { blockedBy: "FN-002" }),
makeTask("FN-205", "todo", { blockedBy: "FN-002" }),
];
render(
<ExecutorStatusBar
tasks={tasks}
staleHighFanoutBlockerAgeThresholdMs={60 * 60 * 1000}
/>,
);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Overlap queue");
expect(statusBar).toHaveTextContent("FN-002 · 5 todo");
});
it("does not show overlap queue summary for ordinary chains below threshold", () => {
const tasks = [
makeTask("FN-500", "in-progress"),
makeTask("FN-501", "todo", { dependencies: ["FN-500"] }),
makeTask("FN-502", "todo", { dependencies: ["FN-500"] }),
makeTask("FN-503", "todo", { dependencies: ["FN-500"] }),
makeTask("FN-504", "todo", { dependencies: ["FN-500"] }),
];
render(<ExecutorStatusBar tasks={tasks} />);
expect(screen.getByRole("status")).not.toHaveTextContent("Escalated");
});
it("displays running task count", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("2");
});
it("displays max concurrent count", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("/");
expect(statusBar).toHaveTextContent("4");
});
it("displays blocked task count", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("1");
});
it("displays queued task count", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("5");
});
it("displays in-review count", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("3");
});
it("renders the terminal launcher in the footer on desktop and opens terminal from the preserved toggle test id", async () => {
const user = userEvent.setup();
const onToggleTerminal = vi.fn();
render(<ExecutorStatusBar tasks={emptyTasks} onToggleTerminal={onToggleTerminal} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
expect(screen.getByTestId("executor-terminal-launcher-segment")).toBeInTheDocument();
await user.click(screen.getByTestId("terminal-toggle-btn"));
expect(onToggleTerminal).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("scripts-btn")).toBeInTheDocument();
expect(await screen.findByTestId("quick-scripts-dropdown")).toBeInTheDocument();
await waitFor(() => expect(mockFetchScripts).toHaveBeenCalledWith(undefined));
});
it("keeps the footer terminal scripts chevron usable when scripts are empty", async () => {
const user = userEvent.setup();
mockFetchScripts.mockResolvedValueOnce({});
render(<ExecutorStatusBar tasks={emptyTasks} onToggleTerminal={vi.fn()} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
await user.click(screen.getByTestId("scripts-btn"));
expect(await screen.findByTestId("quick-scripts-empty")).toBeInTheDocument();
});
it("renders the peer Quick Chat and Terminal footer launchers on tablet", () => {
viewportModeMock.value = "tablet";
render(
<ExecutorStatusBar
tasks={emptyTasks}
onToggleTerminal={vi.fn()}
onOpenScripts={vi.fn()}
onRunScript={vi.fn()}
quickChatButtonMode="footer"
onOpenQuickChat={vi.fn()}
/>,
);
expect(screen.getByTestId("executor-quick-chat-launcher-segment")).toBeInTheDocument();
expect(screen.getByTestId("executor-terminal-launcher-segment")).toBeInTheDocument();
expect(screen.getByTestId("terminal-toggle-btn")).toBeInTheDocument();
});
it("renders the Quick Chat footer launcher beside Terminal when footer mode is enabled", async () => {
const user = userEvent.setup();
const onOpenQuickChat = vi.fn();
render(
<ExecutorStatusBar
tasks={emptyTasks}
onToggleTerminal={vi.fn()}
onOpenScripts={vi.fn()}
onRunScript={vi.fn()}
quickChatButtonMode="footer"
onOpenQuickChat={onOpenQuickChat}
/>,
);
expect(screen.getByTestId("executor-quick-chat-launcher-segment")).toBeInTheDocument();
expect(screen.getByTestId("executor-terminal-launcher-segment")).toBeInTheDocument();
await user.click(screen.getByTestId("executor-quick-chat-launcher"));
expect(onOpenQuickChat).toHaveBeenCalledTimes(1);
});
it("keeps Quick Chat and Terminal footer launchers on the same font and color tokens", () => {
render(
<ExecutorStatusBar
tasks={emptyTasks}
onToggleTerminal={vi.fn()}
onOpenScripts={vi.fn()}
onRunScript={vi.fn()}
quickChatButtonMode="footer"
onOpenQuickChat={vi.fn()}
/>,
);
const quickChatLauncher = screen.getByTestId("executor-quick-chat-launcher");
const terminalLauncher = screen.getByTestId("terminal-toggle-btn");
expect(quickChatLauncher).toHaveClass("executor-status-bar__footer-launcher");
expect(terminalLauncher).toHaveClass("terminal-launcher__main");
expect(screen.getByTestId("executor-quick-chat-launcher-segment")).toBeInTheDocument();
expect(screen.getByTestId("executor-terminal-launcher-segment")).toBeInTheDocument();
const quickChatRule = getCssRuleBlock(executorStatusBarCss, ".executor-status-bar__footer-launcher");
const quickChatHoverRule = getCssRuleBlock(executorStatusBarCss, ".executor-status-bar__footer-launcher:hover");
const quickChatFocusRule = getCssRuleBlock(executorStatusBarCss, ".executor-status-bar__footer-launcher:focus-visible");
const terminalFooterRule = getCssRuleBlock(terminalLauncherCss, ".terminal-launcher--footer");
const terminalControlRule = getCssRuleBlockByPattern(
terminalLauncherCss,
/\.terminal-launcher--footer \.terminal-launcher__main,\s*\.terminal-launcher--footer \.terminal-launcher__chevron\s*\{([^}]*)\}/,
);
const terminalLabelRule = getCssRuleBlock(terminalLauncherCss, ".terminal-launcher--footer .terminal-launcher__label");
const terminalHoverRule = getCssRuleBlockByPattern(
terminalLauncherCss,
/\.terminal-launcher--footer \.terminal-launcher__main:hover,\s*\.terminal-launcher--footer \.terminal-launcher__chevron:hover\s*\{([^}]*)\}/,
);
const terminalFocusRule = getCssRuleBlockByPattern(
terminalLauncherCss,
/\.terminal-launcher--footer \.terminal-launcher__main:focus-visible,\s*\.terminal-launcher--footer \.terminal-launcher__chevron:focus-visible\s*\{([^}]*)\}/,
);
expect(quickChatRule).toContain("color: inherit");
expect(quickChatRule).toContain("font-family: var(--font-primary)");
expect(quickChatRule).toContain("font-size: inherit");
expect(quickChatRule).toContain("font-weight: 500");
expect(quickChatRule).toContain("line-height: 1");
expect(quickChatHoverRule).toContain("color: var(--text)");
expect(quickChatFocusRule).toContain("box-shadow: var(--focus-ring-strong)");
expect(terminalFooterRule).toContain("color: inherit");
expect(terminalFooterRule).toContain("font-family: var(--font-primary)");
expect(terminalFooterRule).toContain("font-size: inherit");
expect(terminalFooterRule).toContain("font-weight: 500");
expect(terminalFooterRule).toContain("line-height: 1");
expect(terminalControlRule).toContain("color: inherit");
expect(terminalControlRule).toContain("font-family: inherit");
expect(terminalControlRule).toContain("font-size: inherit");
expect(terminalControlRule).toContain("font-weight: inherit");
expect(terminalControlRule).toContain("line-height: inherit");
expect(terminalLabelRule).toContain("font-size: inherit");
expect(terminalLabelRule).toContain("font-weight: inherit");
expect(terminalLabelRule).toContain("line-height: inherit");
expect(terminalHoverRule).toContain("color: var(--text)");
expect(terminalFocusRule).toContain("box-shadow: var(--focus-ring-strong)");
[quickChatRule, quickChatHoverRule, quickChatFocusRule, terminalFooterRule, terminalControlRule, terminalLabelRule, terminalHoverRule, terminalFocusRule].forEach(expectNoHardcodedColors);
});
it("omits the Quick Chat footer launcher for floating, off, and mobile modes", () => {
const { rerender } = render(
<ExecutorStatusBar
tasks={emptyTasks}
quickChatButtonMode="floating"
onOpenQuickChat={vi.fn()}
/>,
);
expect(screen.queryByTestId("executor-quick-chat-launcher-segment")).toBeNull();
rerender(
<ExecutorStatusBar
tasks={emptyTasks}
quickChatButtonMode="off"
onOpenQuickChat={vi.fn()}
/>,
);
expect(screen.queryByTestId("executor-quick-chat-launcher-segment")).toBeNull();
viewportModeMock.value = "mobile";
rerender(
<ExecutorStatusBar
tasks={emptyTasks}
quickChatButtonMode="footer"
onOpenQuickChat={vi.fn()}
/>,
);
expect(screen.queryByTestId("executor-quick-chat-launcher-segment")).toBeNull();
});
it("omits the terminal launcher from the footer on mobile", () => {
viewportModeMock.value = "mobile";
render(<ExecutorStatusBar tasks={emptyTasks} onToggleTerminal={vi.fn()} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
expect(screen.queryByTestId("executor-terminal-launcher-segment")).toBeNull();
expect(screen.queryByTestId("terminal-toggle-btn")).toBeNull();
expect(screen.queryByTestId("scripts-btn")).toBeNull();
});
it("does not show stuck tasks segment when count is 0", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.queryByText("Stuck")).not.toBeInTheDocument();
});
it("shows stuck tasks segment when count is > 0", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, stuckTaskCount: 2 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Stuck");
expect(statusBar).toHaveTextContent("2");
});
});
describe("executor state", () => {
it("shows Running state with running executorState", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
const stateElement = statusBar.querySelector(".executor-status-bar__state");
expect(stateElement).toHaveTextContent("Running");
});
it("renders footer engine controls next to Running state and opens from the small trigger", async () => {
const user = userEvent.setup();
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar.querySelector(".executor-status-bar__segment--engine-controls")).toHaveTextContent("Running");
await user.click(screen.getByTestId("engine-control-menu-trigger"));
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
});
it("opens footer engine controls from the executor state text", async () => {
const user = userEvent.setup();
render(<ExecutorStatusBar tasks={emptyTasks} />);
await user.click(screen.getByTestId("executor-state-engine-control-trigger"));
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
});
it("shows Paused state with paused executorState", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, executorState: "paused" },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
const stateElement = statusBar.querySelector(".executor-status-bar__state");
expect(stateElement).toHaveTextContent("Paused");
});
it("shows Idle state with idle executorState", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, executorState: "idle", runningTaskCount: 0 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
const stateElement = statusBar.querySelector(".executor-status-bar__state");
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} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveClass("executor-status-bar--running");
});
it("does not apply running class when executor is paused", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, executorState: "paused" },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).not.toHaveClass("executor-status-bar--running");
});
it("does not apply running class when executor is idle", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, executorState: "idle", runningTaskCount: 0 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).not.toHaveClass("executor-status-bar--running");
});
});
describe("loading state", () => {
it("shows loading text when loading and no running tasks", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, runningTaskCount: 0 },
loading: true,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByLabelText("Executor status");
expect(statusBar).toHaveTextContent("Loading...");
expect(statusBar).toHaveClass("executor-status-bar--loading");
});
it("renders the populated idle footer instead of loading when loaded data has zero running tasks", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, executorState: "idle", runningTaskCount: 0, queuedTaskCount: 0 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Idle");
expect(statusBar).toHaveTextContent("Queued");
expect(statusBar).not.toHaveClass("executor-status-bar--loading");
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
expect(screen.getByTestId("engine-control-menu-trigger")).toBeInTheDocument();
});
it("keeps an open concurrency popover mounted across idle heartbeat rerenders", async () => {
const user = userEvent.setup();
const idleStats = { ...defaultStats, executorState: "idle" as const, runningTaskCount: 0, queuedTaskCount: 0 };
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: idleStats,
loading: false,
error: null,
refresh: vi.fn(),
});
const { rerender } = render(<ExecutorStatusBar tasks={emptyTasks} lastFetchTimeMs={1000} />);
await user.click(screen.getByTestId("engine-control-menu-trigger"));
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...idleStats, lastActivityAt: "2026-06-27T21:40:00.000Z" },
loading: false,
error: null,
refresh: vi.fn(),
});
rerender(<ExecutorStatusBar tasks={[...emptyTasks]} lastFetchTimeMs={6000} />);
expect(screen.getByRole("status")).not.toHaveClass("executor-status-bar--loading");
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
});
it("does not swap to loading or close the popover if a future idle heartbeat reports loading with data", async () => {
viewportModeMock.value = "mobile";
const user = userEvent.setup();
const idleStats = { ...defaultStats, executorState: "idle" as const, runningTaskCount: 0, queuedTaskCount: 0 };
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: idleStats,
loading: false,
error: null,
refresh: vi.fn(),
});
const { rerender } = render(<ExecutorStatusBar tasks={emptyTasks} />);
await user.click(screen.getByTestId("engine-control-menu-trigger"));
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...idleStats, lastActivityAt: "2026-06-27T21:45:00.000Z" },
loading: true,
error: null,
refresh: vi.fn(),
});
rerender(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Idle");
expect(statusBar).not.toHaveClass("executor-status-bar--loading");
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument();
});
it("does not show loading text when not loading", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
it("does not show loading text when loading but running tasks exist", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: true,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
});
describe("error state", () => {
it.each(["desktop", "mobile"] as const)("keeps populated stats on %s when a transient stats-fetch blip is debounced", (viewportMode) => {
viewportModeMock.value = viewportMode;
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Queued");
expect(statusBar).toHaveTextContent("Running");
expect(statusBar).toHaveTextContent("Blocked");
expect(statusBar).toHaveTextContent("In Review");
expect(statusBar).not.toHaveClass("executor-status-bar--connecting");
expect(statusBar.querySelector(".executor-status-bar--connecting")).toBeNull();
expect(screen.queryByText("Connecting…")).not.toBeInTheDocument();
});
it("shows error message when error is present", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
error: "Stats unavailable",
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Stats unavailable");
expect(statusBar).toHaveClass("executor-status-bar--error");
});
it.each(["desktop", "mobile"] as const)("shows connecting state for sustained suspension errors on %s", (viewportMode) => {
viewportModeMock.value = viewportMode;
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
error: "Failed to fetch",
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Connecting…");
expect(statusBar).not.toHaveTextContent("Failed to fetch");
expect(statusBar).toHaveClass("executor-status-bar--connecting");
});
it("does not show stat segments when error is present", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
error: "Failed to fetch stats",
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
// The error bar shouldn't have the running segment
expect(statusBar).not.toHaveTextContent("Running");
});
});
describe("accessibility", () => {
it("has role status", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("has aria-label", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.getByRole("status")).toHaveAttribute("aria-label", "Executor status");
});
it("applies warning class to blocked count when blocked tasks exist", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
// Get the status bar and look for the blocked count element
const statusBar = screen.getByRole("status");
const blockedSegment = statusBar.querySelector(".executor-status-bar__indicator--blocked");
expect(blockedSegment?.parentElement?.querySelector(".executor-status-bar__count")).toHaveClass("executor-status-bar__count--warning");
});
it("applies error class to stuck count when stuck tasks exist", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, stuckTaskCount: 1 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
const stuckSegment = statusBar.querySelector(".executor-status-bar__segment--stuck");
expect(stuckSegment?.querySelector(".executor-status-bar__count")).toHaveClass("executor-status-bar__count--error");
});
it("applies active class to running indicator when tasks are running", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveClass("executor-status-bar--running");
});
});
describe("visual states", () => {
it("shows warning styling when blocked tasks exist", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const blockedCount = screen.getByText("1");
expect(blockedCount).toHaveClass("executor-status-bar__count--warning");
});
it("does not show warning styling when no blocked tasks", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, blockedTaskCount: 0 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const counts = screen.queryAllByText("0");
// First one is running count which shouldn't have warning
// We need to check the blocked one specifically
});
});
describe("project context", () => {
it("passes tasks and projectId to useExecutorStats when provided", () => {
const tasks: any[] = [{ id: "FN-001" }];
render(<ExecutorStatusBar tasks={tasks} projectId="proj_abc123" />);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123", undefined, undefined);
});
it("passes tasks and undefined to useExecutorStats when projectId not provided", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined, undefined);
});
});
describe("project directory toggle", () => {
it("reveals and hides the project path when the folder toggle is clicked", async () => {
const user = userEvent.setup();
render(
<ExecutorStatusBar
tasks={emptyTasks}
currentProjectPath="/workspace/project"
onOpenProjectDirectory={vi.fn()}
/>
);
expect(screen.queryByTestId("executor-project-path-link")).not.toBeInTheDocument();
await user.click(screen.getByTestId("executor-project-path-toggle"));
expect(screen.getByTestId("executor-project-path-link")).toHaveTextContent("/workspace/project");
await user.click(screen.getByTestId("executor-project-path-toggle"));
expect(screen.queryByTestId("executor-project-path-link")).not.toBeInTheDocument();
});
it("calls onOpenProjectDirectory when the visible project path is clicked", async () => {
const user = userEvent.setup();
const onOpenProjectDirectory = vi.fn();
render(
<ExecutorStatusBar
tasks={emptyTasks}
currentProjectPath="/workspace/project"
onOpenProjectDirectory={onOpenProjectDirectory}
/>
);
await user.click(screen.getByTestId("executor-project-path-toggle"));
await user.click(screen.getByTestId("executor-project-path-link"));
expect(onOpenProjectDirectory).toHaveBeenCalledTimes(1);
});
});
describe("time display", () => {
it("displays relative time for recent activity", () => {
const now = new Date();
const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000).toISOString();
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, lastActivityAt: twoMinutesAgo },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.getByText("2m ago")).toBeInTheDocument();
});
it("displays 'no activity' when lastActivityAt is undefined", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, lastActivityAt: undefined },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.getByText("no activity")).toBeInTheDocument();
});
});
describe("board-sync regression", () => {
it("requires tasks prop — does not fetch its own task list", () => {
// This test verifies the component receives tasks from its parent
// rather than creating its own useTasks instance, which was the
// root cause of the footer/board count mismatch.
const tasks: any[] = [{ id: "FN-001" }];
render(<ExecutorStatusBar tasks={tasks} />);
// useExecutorStats receives the tasks array as first argument
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined, undefined);
});
it("renders stuck segment with correct count when stuck tasks detected", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, stuckTaskCount: 3, runningTaskCount: 2 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Stuck");
const stuckCount = statusBar.querySelector(".executor-status-bar__segment--stuck .executor-status-bar__count");
expect(stuckCount).toHaveTextContent("3");
expect(stuckCount).toHaveClass("executor-status-bar__count--error");
});
});
describe("mobile keyboard behavior", () => {
it("hides bar when hideWhenKeyboardOpen is true", () => {
const { container } = render(<ExecutorStatusBar tasks={emptyTasks} hideWhenKeyboardOpen={true} />);
expect(container.firstChild).toBeNull();
});
it("applies keyboard-open class when keyboardOpen is true", () => {
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={true} />);
expect(screen.getByRole("status")).toHaveClass("executor-status-bar--keyboard-open");
});
it("does not apply keyboard-open class and remains rendered when keyboardOpen is false", () => {
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={false} />);
const status = screen.getByRole("status");
expect(status).toBeInTheDocument();
expect(status).not.toHaveClass("executor-status-bar--keyboard-open");
});
});
describe("layout integration", () => {
it("exposes stable executor-status-bar class for external layout hooks", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
// The parent layout relies on the executor-status-bar class to detect
// the footer's presence and set the --executor-footer-height CSS token.
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveClass("executor-status-bar");
});
it("uses role=status for accessibility and layout targeting", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
// The status role serves dual purpose: a11y landmark and a stable
// selector for the project-content wrapper to detect footer presence.
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveAttribute("aria-label", "Executor status");
});
it("always renders a root element with executor-status-bar class regardless of state", () => {
// Test loading state
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: true,
error: null,
refresh: vi.fn(),
});
const { unmount } = render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.getByRole("status")).toHaveClass("executor-status-bar");
unmount();
// Test error state
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
error: "Connection failed",
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.getByRole("status")).toHaveClass("executor-status-bar");
});
});
});