feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @file RunConsole.test.tsx
|
||||
* @description Pins the props-only boundary of `RunConsole` after its move out
|
||||
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
|
||||
* (no stream subscription of its own), the token meter rolls up usage from
|
||||
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
|
||||
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
|
||||
* send and stop controls.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RunConsole, type SlashCommand } from "../RunConsole";
|
||||
import type { Envelope } from "../../../hooks/useRunStream";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
|
||||
const HANDLE: RunHandle = {
|
||||
id: "run-1",
|
||||
pid: 4242,
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-5",
|
||||
permissionMode: "acceptEdits",
|
||||
effort: "",
|
||||
prompt: "hi",
|
||||
argv: [],
|
||||
resumeSessionId: null,
|
||||
status: "running",
|
||||
startedAt: 1,
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
sessionId: null,
|
||||
envelopeCount: 0,
|
||||
stdoutTail: "",
|
||||
stderrTail: "",
|
||||
};
|
||||
|
||||
const COMMANDS: SlashCommand[] = [
|
||||
{ name: "code-review", description: "Review the working diff", source: "project" },
|
||||
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
|
||||
{ name: "logout", description: "Sign out", source: "builtin" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mount the console with the parent-owned follow-up state it expects, so the
|
||||
* autocomplete assertions exercise the real controlled-input round trip.
|
||||
*/
|
||||
function renderConsole(
|
||||
props: Partial<React.ComponentProps<typeof RunConsole>> = {},
|
||||
onFollowUp?: (s: string) => void
|
||||
) {
|
||||
const seen = { followUp: "" };
|
||||
function Harness() {
|
||||
const [followUp, setFollowUp] = useState("");
|
||||
seen.followUp = followUp;
|
||||
return (
|
||||
<RunConsole
|
||||
handle={HANDLE}
|
||||
envelopes={[]}
|
||||
mode="conversation"
|
||||
isLive
|
||||
hasFinished={false}
|
||||
followUp={followUp}
|
||||
onFollowUpChange={(s) => {
|
||||
setFollowUp(s);
|
||||
onFollowUp?.(s);
|
||||
}}
|
||||
busy={null}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
onNewRun={() => {}}
|
||||
slashCommands={COMMANDS}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Harness />
|
||||
</MemoryRouter>
|
||||
);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("RunConsole", () => {
|
||||
it("renders assistant text from the envelopes prop", () => {
|
||||
const envelopes: Envelope[] = [
|
||||
{ type: "user", message: { content: "explain this repo" } },
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } },
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
expect(screen.getByText("explain this repo")).toBeInTheDocument();
|
||||
expect(screen.getByText("Here is the answer.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty-stream placeholder when there are no envelopes", () => {
|
||||
renderConsole({ isLive: false });
|
||||
expect(screen.getByText("Nothing yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the token totals computed from the envelopes", () => {
|
||||
// Transcript-shaped assistant envelope (no `message.id`), which is the
|
||||
// branch computeTokens folds into the running totals.
|
||||
const envelopes: Envelope[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "done" }],
|
||||
usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 },
|
||||
},
|
||||
},
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
// Context gauge: (input + cache read) / default 200k window.
|
||||
// The CLI-style meter is one status line: context usage as a single label,
|
||||
// then output and cache-hit figures with terminal glyphs. Input is implied
|
||||
// by the context total rather than listed separately.
|
||||
expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output
|
||||
expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit
|
||||
});
|
||||
|
||||
it("filters slash commands as the user types and fills the prompt on pick", () => {
|
||||
const seen = renderConsole();
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/co" } });
|
||||
|
||||
expect(screen.getByText("/code-review")).toBeInTheDocument();
|
||||
expect(screen.getByText("/compact")).toBeInTheDocument();
|
||||
expect(screen.queryByText("/logout")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("/code-review"));
|
||||
|
||||
expect(seen.followUp).toBe("/code-review");
|
||||
expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed
|
||||
});
|
||||
|
||||
it("fires onSend from the send button with the prompt the parent holds", () => {
|
||||
const onSend = vi.fn();
|
||||
const seen = renderConsole({ onSend });
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(seen.followUp).toBe("follow up please");
|
||||
});
|
||||
|
||||
it("fires onStop from the stop control while live, and hides it when not", () => {
|
||||
const onStop = vi.fn();
|
||||
renderConsole({ onStop });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /stop/i }));
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the stop control and the follow-up editor once the run is not live", () => {
|
||||
renderConsole({ isLive: false, hasFinished: true });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file RunHistory.test.tsx
|
||||
* @description Pins the props-only boundary of `ActiveRunsSwitcher` / `RunsModal`
|
||||
* after their move out of `pages/Run.tsx`: the switcher counts live runs and
|
||||
* opens the list, the list merges live in-memory handles with persistent history
|
||||
* (live entries winning on a shared id) newest first, marks the live and current
|
||||
* rows, filters by status / mode / free text, and fires attach / resume / view
|
||||
* with the right run — attach by run id, resume and view with the matching
|
||||
* history item.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||
|
||||
const LIVE_ID = "run-live";
|
||||
const PAST_ID = "run-past";
|
||||
const HEADLESS_ID = "run-headless";
|
||||
|
||||
const activeRuns = {
|
||||
activeCount: 1,
|
||||
maxConcurrent: 2,
|
||||
items: [
|
||||
{
|
||||
id: LIVE_ID,
|
||||
sessionId: "sess-live",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/live",
|
||||
model: "claude-opus-5",
|
||||
status: "running",
|
||||
prompt: "the live prompt",
|
||||
startedAt: 3000,
|
||||
endedAt: null,
|
||||
},
|
||||
],
|
||||
} as unknown as RunListResponse;
|
||||
|
||||
function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistoryItem {
|
||||
return {
|
||||
id: PAST_ID,
|
||||
session_id: "sess-past",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/past",
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
prompt_preview: "the past prompt",
|
||||
started_at: new Date(2000).toISOString(),
|
||||
ended_at: new Date(2500).toISOString(),
|
||||
exit_code: 0,
|
||||
permission_mode: "acceptEdits",
|
||||
effort: "",
|
||||
resume_session_id: null,
|
||||
isLive: false,
|
||||
...over,
|
||||
} as DashboardRunHistoryItem;
|
||||
}
|
||||
|
||||
const PAST = historyItem({});
|
||||
const HEADLESS = historyItem({
|
||||
id: HEADLESS_ID,
|
||||
session_id: "sess-headless",
|
||||
mode: "headless",
|
||||
cwd: "/tmp/headless",
|
||||
prompt_preview: "the headless prompt",
|
||||
started_at: new Date(1000).toISOString(),
|
||||
});
|
||||
|
||||
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onViewFromHistory: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
currentHandleId={null}
|
||||
runHistory={[PAST, HEADLESS]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
|
||||
return {
|
||||
id,
|
||||
sessionId: `sess-${id}`,
|
||||
mode: "conversation",
|
||||
cwd: `/tmp/${id}`,
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
promptPreview: `prompt of ${id}`,
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
isLive: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function renderModal(
|
||||
rows: UnifiedRunRow[],
|
||||
overrides: Partial<React.ComponentProps<typeof RunsModal>> = {}
|
||||
) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunsModal
|
||||
rows={rows}
|
||||
currentHandleId={null}
|
||||
runHistory={[PAST, HEADLESS]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
/** The nth "All" chip — index 0 is the status group, 1 is the mode group. */
|
||||
function allChip(nth: number): HTMLElement {
|
||||
const hits = screen.getAllByText(i18n.t("run:runs.allLabel", "All"));
|
||||
const hit = hits[nth];
|
||||
if (!hit) throw new Error(`no "All" chip at index ${nth}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** A filter chip, told apart from the same word appearing in a row's status
|
||||
* pill or mode badge by being a `<button>`. */
|
||||
function chip(label: string): HTMLElement {
|
||||
const hit = screen.getAllByText(label).find((el) => el.tagName === "BUTTON");
|
||||
if (!hit) throw new Error(`no filter chip labelled ${label}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
const openModal = () =>
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ActiveRunsSwitcher", () => {
|
||||
it("labels the button with the live count and opens the list", () => {
|
||||
renderSwitcher();
|
||||
openModal();
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.getByText("the past prompt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the total count when nothing is live, and disables at zero", () => {
|
||||
const { unmount } = renderSwitcher({ activeRuns: null });
|
||||
expect(screen.getByText(i18n.t("run:runs.switcher"))).toBeTruthy();
|
||||
expect(screen.getByText("2")).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
renderSwitcher({ activeRuns: null, runHistory: [] });
|
||||
const button = screen.getByText(i18n.t("run:runs.switcher")).closest("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("lists live runs first and marks the live one", () => {
|
||||
renderSwitcher();
|
||||
openModal();
|
||||
const prompts = screen
|
||||
.getAllByText(/the (live|past|headless) prompt/)
|
||||
.map((el) => el.textContent);
|
||||
expect(prompts).toEqual(["the live prompt", "the past prompt", "the headless prompt"]);
|
||||
expect(screen.getAllByText("live")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prefers the live handle over a history row with the same id", () => {
|
||||
renderSwitcher({ runHistory: [historyItem({ id: LIVE_ID, prompt_preview: "stale copy" })] });
|
||||
openModal();
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.queryByText("stale copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires attach with the run id of the row that was clicked", () => {
|
||||
const { spies } = renderSwitcher();
|
||||
openModal();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
// Attaching closes the list, which is what the page relies on.
|
||||
expect(screen.queryByText("the past prompt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunsModal", () => {
|
||||
it("offers Attach only for a live row that is not the current one", () => {
|
||||
const { unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })], {
|
||||
currentHandleId: LIVE_ID,
|
||||
});
|
||||
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
||||
expect(screen.getByText(i18n.t("run:runs.currentBadge", "current"))).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
const { spies } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
});
|
||||
|
||||
it("fires resume with the history item behind a finished conversation row", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
||||
expect(spies.onView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires view — not resume — for a finished headless row", () => {
|
||||
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
|
||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
||||
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
||||
expect(spies.onResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by status, by mode and by free text", () => {
|
||||
const rows = [
|
||||
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
||||
row("b", { status: "error", promptPreview: "bravo" }),
|
||||
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
|
||||
];
|
||||
renderModal(rows);
|
||||
|
||||
fireEvent.click(chip(i18n.t("run:status.error")));
|
||||
expect(screen.getByText("bravo")).toBeTruthy();
|
||||
expect(screen.queryByText("alpha")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(0));
|
||||
fireEvent.click(chip(i18n.t("run:mode.headless")));
|
||||
expect(screen.getByText("charlie")).toBeTruthy();
|
||||
expect(screen.queryByText("bravo")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(1));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(
|
||||
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
||||
),
|
||||
{ target: { value: "alpha" } }
|
||||
);
|
||||
expect(screen.getByText("alpha")).toBeTruthy();
|
||||
expect(screen.queryByText("charlie")).toBeNull();
|
||||
});
|
||||
|
||||
it("polls onRefresh while it is the foreground UI", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { spies } = renderModal([row("a")]);
|
||||
expect(spies.onRefresh).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(spies.onRefresh).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @file RunSetup.test.tsx
|
||||
* @description Pins the props-only boundary of `RunSetup` after its move out of
|
||||
* `pages/Run.tsx` (where it was `ConfigCard`): every picker the panel owns —
|
||||
* mode, prompt, cwd, model, permission mode, effort — reports its selection
|
||||
* through the matching callback and nowhere else, `onStart` fires from the Run
|
||||
* button, and a missing `claude` binary is surfaced purely from the
|
||||
* `binaryFound` prop (the panel runs no probe of its own).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SUGGESTIONS: CwdSuggestion[] = [
|
||||
{ kind: "home", path: "/Users/tester", label: "Home" },
|
||||
{ kind: "recent", path: "/Users/tester/projects/other", label: "other" },
|
||||
];
|
||||
|
||||
type Spies = ReturnType<typeof renderSetup>["spies"];
|
||||
|
||||
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
||||
const spies = {
|
||||
onModeChange: vi.fn(),
|
||||
onPromptChange: vi.fn(),
|
||||
onCwdChange: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onPermissionModeChange: vi.fn(),
|
||||
onEffortChange: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onResumeSessionChange: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
{...spies}
|
||||
{...overrides}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
return { ...utils, spies };
|
||||
}
|
||||
|
||||
/** Open the `Select` whose trigger currently shows `currentLabel`, then pick
|
||||
* the option labelled `optionLabel`. */
|
||||
function pickFromSelect(currentLabel: string, optionLabel: string) {
|
||||
fireEvent.click(screen.getByText(currentLabel));
|
||||
fireEvent.click(screen.getByText(optionLabel));
|
||||
}
|
||||
|
||||
/** Every callback except the named ones must stay untouched — a selection that
|
||||
* leaks into a sibling prop is exactly the wiring bug a move can introduce. */
|
||||
function onlyCalled(spies: Spies, ...called: (keyof Spies)[]) {
|
||||
for (const [name, spy] of Object.entries(spies)) {
|
||||
if (called.includes(name as keyof Spies)) continue;
|
||||
expect(spy, `${name} should not have fired`).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RunSetup — selections report through callbacks", () => {
|
||||
it("reports the mode from the one-shot / conversation options", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
|
||||
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
|
||||
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
|
||||
onlyCalled(spies, "onModeChange");
|
||||
});
|
||||
|
||||
it("reports the prompt from the editor", () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||
fireEvent.change(box, { target: { value: "review the diff" } });
|
||||
expect(spies.onPromptChange).toHaveBeenCalledWith("review the diff");
|
||||
onlyCalled(spies, "onPromptChange");
|
||||
});
|
||||
|
||||
it("reports the cwd from typing and from a suggestion", () => {
|
||||
const { spies } = renderSetup();
|
||||
const input = screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder"));
|
||||
fireEvent.change(input, { target: { value: "/tmp/pro" } });
|
||||
expect(spies.onCwdChange).toHaveBeenCalledWith("/tmp/pro");
|
||||
fireEvent.click(screen.getByText("/Users/tester/projects/other"));
|
||||
expect(spies.onCwdChange).toHaveBeenLastCalledWith("/Users/tester/projects/other");
|
||||
onlyCalled(spies, "onCwdChange");
|
||||
});
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 4.6");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
|
||||
expect(spies.onModelChange).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
|
||||
target: { value: "claude-opus-5" },
|
||||
});
|
||||
expect(spies.onModelChange).toHaveBeenLastCalledWith("claude-opus-5");
|
||||
onlyCalled(spies, "onModelChange");
|
||||
});
|
||||
|
||||
it("reports the permission mode and the effort level", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.permissionAcceptEdits"), i18n.t("run:fields.permissionPlan"));
|
||||
expect(spies.onPermissionModeChange).toHaveBeenCalledWith("plan");
|
||||
pickFromSelect("Default (model decides)", "Medium");
|
||||
expect(spies.onEffortChange).toHaveBeenCalledWith("medium");
|
||||
onlyCalled(spies, "onPermissionModeChange", "onEffortChange");
|
||||
});
|
||||
|
||||
it("fires onStart from the Run button", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledTimes(1);
|
||||
onlyCalled(spies, "onStart");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — missing binary and other blocked states", () => {
|
||||
/** The Run button, found by its label rather than by DOM position. */
|
||||
function runButton(): HTMLButtonElement {
|
||||
return screen.getByText(i18n.t("run:actions.start")).closest("button") as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it("disables Run when the claude binary was not found", () => {
|
||||
renderSetup({ binaryFound: false });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables Run when the binary is found and the form is complete", () => {
|
||||
renderSetup();
|
||||
expect(runButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => {
|
||||
const { unmount } = renderSetup({ prompt: " " });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
unmount();
|
||||
|
||||
const noCwd = renderSetup({ cwd: "" });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
noCwd.unmount();
|
||||
|
||||
renderSetup({
|
||||
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
|
||||
});
|
||||
expect(runButton().disabled).toBe(true);
|
||||
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the Starting… label while busy", () => {
|
||||
renderSetup({ busy: true });
|
||||
expect(screen.getByText(i18n.t("run:actions.starting"))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — resume picker scopes sessions to the selected lane", () => {
|
||||
it("passes the lane's cwd as a filter and lists only that directory's sessions", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// The API call itself is what enforces the scope - the server filters by
|
||||
// cwd, so the picker must never fetch without one when a lane is selected.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-a" })
|
||||
);
|
||||
});
|
||||
|
||||
it("lists everything when no lane is selected", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
renderSetup({ laneCwd: undefined });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ cwd: undefined }));
|
||||
});
|
||||
|
||||
it("re-fetches with the new cwd when the selected lane changes", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
const { rerender } = renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
model=""
|
||||
permissionMode="acceptEdits"
|
||||
effort=""
|
||||
binaryFound
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
onModeChange={vi.fn()}
|
||||
onPromptChange={vi.fn()}
|
||||
onCwdChange={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPermissionModeChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onResumeSessionChange={vi.fn()}
|
||||
onResumeFromHistory={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/Users/tester/lane-b" })
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user