428519335d
- The run form's prompt box offers suggestions again: `/` lists the run
cwd's project and user commands and skills plus the CLI built-ins
(claude lanes only), `@` lists files under the cwd; Enter picks while
the list is open and sends otherwise. The 2f39f4e rewrite had left a
bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
(sessions matched to the lane by longest path-boundary cwd), instead of
every lane's.
- The Changes panel's git line and its actions are one compact row in one
card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
leaving an empty frame where the git row used to be.
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
/**
|
|
* @file LaneConsolePane.test.tsx
|
|
* @description Test suite for the LaneConsolePane component
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { LaneConsolePane } from "../LaneConsolePane";
|
|
import { api } from "../../../lib/api";
|
|
import type { Lane } from "../../../lib/types";
|
|
|
|
vi.mock("../TerminalView", () => ({
|
|
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
|
// out of claude), which the real TerminalView reports through `onExit`.
|
|
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
|
|
<div data-testid="terminal-view" data-run-id={runId}>
|
|
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../../../lib/api", () => ({
|
|
api: {
|
|
lanes: {
|
|
ensure: vi.fn(),
|
|
action: vi.fn(),
|
|
list: vi.fn(),
|
|
},
|
|
run: {
|
|
list: vi.fn().mockResolvedValue({ items: [] }),
|
|
history: vi.fn().mockResolvedValue({ items: [] }),
|
|
get: vi.fn(),
|
|
start: vi.fn(),
|
|
},
|
|
},
|
|
RUN_MODEL_CHOICES: [],
|
|
RUN_MODEL_CHOICES_CODEX: [],
|
|
RUN_EFFORT_CHOICES: [],
|
|
}));
|
|
|
|
const LANE: Lane = {
|
|
id: 1,
|
|
router_profile: null,
|
|
title: "demo",
|
|
cwd: "/workspace/a",
|
|
branch: null,
|
|
kind: "adopted",
|
|
tool: "claude",
|
|
source_repo: null,
|
|
pipeline: "default",
|
|
session_id: null,
|
|
run_id: null,
|
|
stage: "idle",
|
|
stage_since: null,
|
|
status: "idle",
|
|
gate_decision: null,
|
|
ci_status: null,
|
|
needs_action: null,
|
|
links: {},
|
|
stages: {},
|
|
notes: null,
|
|
pipeline_name: "Default",
|
|
pipeline_nodes: [],
|
|
progress: 0,
|
|
stage_seconds: null,
|
|
last_event_seconds: null,
|
|
liveness: "idle" as Lane["liveness"],
|
|
detected_stage: null,
|
|
detected_signal: null,
|
|
slot: null,
|
|
ports: {},
|
|
active_feature_id: null,
|
|
};
|
|
|
|
function baseProps() {
|
|
return {
|
|
lanes: [LANE],
|
|
laneId: 1,
|
|
showLaneSelector: false,
|
|
onLaneIdChange: vi.fn(),
|
|
onLaneCreated: vi.fn(),
|
|
binaryStatus: {
|
|
claude: { found: true, path: "/usr/local/bin/claude" },
|
|
codex: { found: true, path: "/usr/local/bin/codex" },
|
|
},
|
|
cwdSuggestions: [],
|
|
activeRuns: { items: [] },
|
|
wsConnected: true,
|
|
};
|
|
}
|
|
|
|
describe("LaneConsolePane", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("starts a run through /api/lanes/<id>/start, not /api/run/start", async () => {
|
|
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
lane: { ...LANE, run_id: "run-1" },
|
|
});
|
|
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
id: "run-1",
|
|
laneId: 1,
|
|
status: "running",
|
|
cwd: "/workspace/a",
|
|
model: null,
|
|
permissionMode: null,
|
|
effort: null,
|
|
resumeSessionId: null,
|
|
sessionId: null,
|
|
startedAt: null,
|
|
promptPreview: null,
|
|
});
|
|
|
|
render(<LaneConsolePane {...baseProps()} />);
|
|
|
|
// Set cwd and prompt
|
|
const cwdInput = screen.getByPlaceholderText(/type to search/i);
|
|
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } });
|
|
|
|
const promptTextarea = screen.getByPlaceholderText(/ask claude/i);
|
|
fireEvent.change(promptTextarea, { target: { value: "test prompt" } });
|
|
|
|
// Find and click the Run button (the main start button in RunSetup)
|
|
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object))
|
|
);
|
|
expect(api.run.start).not.toHaveBeenCalled();
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1")
|
|
);
|
|
});
|
|
|
|
it("returns to the setup form when the pane exits, without a New run click", async () => {
|
|
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
lane: { ...LANE, run_id: "run-1" },
|
|
});
|
|
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
id: "run-1",
|
|
laneId: 1,
|
|
status: "running",
|
|
cwd: "/workspace/a",
|
|
model: null,
|
|
permissionMode: null,
|
|
effort: null,
|
|
resumeSessionId: null,
|
|
sessionId: null,
|
|
startedAt: null,
|
|
promptPreview: null,
|
|
});
|
|
|
|
render(<LaneConsolePane {...baseProps()} />);
|
|
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
|
await screen.findByTestId("terminal-view");
|
|
|
|
fireEvent.click(screen.getByTestId("fake-pane-exit"));
|
|
|
|
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
|
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
|
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
|
const run = (id: string, laneId: number) => ({
|
|
id,
|
|
laneId,
|
|
status: "running" as const,
|
|
cwd: null,
|
|
model: null,
|
|
permissionMode: null,
|
|
effort: null,
|
|
resumeSessionId: null,
|
|
sessionId: null,
|
|
startedAt: null,
|
|
promptPreview: null,
|
|
tool: "claude" as const,
|
|
});
|
|
const props = {
|
|
...baseProps(),
|
|
lanes: [LANE, LANE2],
|
|
activeRuns: { items: [run("agenthub-lane-1", 1), run("agenthub-lane-2", 2)] },
|
|
};
|
|
|
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-1")
|
|
);
|
|
|
|
rerender(<LaneConsolePane {...props} laneId={2} />);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-2")
|
|
);
|
|
|
|
// A lane with no live run falls back to its setup form, not the previous
|
|
// lane's terminal.
|
|
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
|
|
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
|
});
|
|
|
|
it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
|
|
// Split view restores its pane lanes from localStorage, so a pane can
|
|
// render with a laneId before GET /api/lanes has answered. laneId never
|
|
// changes afterwards — only the resolved lane does.
|
|
const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
|
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
|
const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
|
|
expect(cwdInput.value).toBe("/home/tester");
|
|
|
|
rerender(<LaneConsolePane {...props} lanes={[LANE]} />);
|
|
await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
|
|
});
|
|
|
|
it("shows a lane dropdown only when showLaneSelector is true", () => {
|
|
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
|
|
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
|
|
|
rerender(<LaneConsolePane {...baseProps()} showLaneSelector={false} />);
|
|
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
|
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
|
|
expect(screen.getByTestId("console-body")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("never pre-fills the prompt from the lane's pipeline, for either tool", async () => {
|
|
// Picking a pipeline used to write its driving slash command (claude) or a
|
|
// stage checklist (codex) into the box — an extra prompt sent on start.
|
|
const claudeLane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
|
|
const codexLane = {
|
|
...LANE,
|
|
tool: "codex" as const,
|
|
pipeline_skill: "/agenthub:ship-feature-lane",
|
|
pipeline_nodes: [
|
|
{
|
|
id: "implement",
|
|
label: "Implement",
|
|
icon: "code",
|
|
gate: false,
|
|
state: "pending" as const,
|
|
},
|
|
{ id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const },
|
|
],
|
|
};
|
|
const props = { ...baseProps(), lanes: [claudeLane] };
|
|
const { rerender } = render(<LaneConsolePane {...props} />);
|
|
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
|
await waitFor(() => expect(prompt.value).toBe(""));
|
|
|
|
// A typed requirement still survives a template switch.
|
|
fireEvent.change(prompt, { target: { value: "add a button" } });
|
|
rerender(
|
|
<LaneConsolePane
|
|
{...props}
|
|
lanes={[{ ...claudeLane, pipeline_skill: "/agenthub:ship-feature-lane" }]}
|
|
/>
|
|
);
|
|
await waitFor(() => expect(prompt.value).toBe("add a button"));
|
|
|
|
rerender(<LaneConsolePane {...props} lanes={[codexLane]} />);
|
|
await waitFor(() => expect(prompt.value).toBe("add a button"));
|
|
});
|
|
|
|
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
|
|
const HISTORY_ITEM = {
|
|
id: "run-old",
|
|
session_id: "sess-old",
|
|
cwd: "/workspace/a",
|
|
model: null,
|
|
permission_mode: null,
|
|
effort: null,
|
|
resume_session_id: null,
|
|
prompt_preview: "earlier prompt",
|
|
status: "abandoned" as const,
|
|
exit_code: null,
|
|
started_at: new Date(1000).toISOString(),
|
|
ended_at: new Date(2000).toISOString(),
|
|
isLive: false,
|
|
tool: "claude" as const,
|
|
};
|
|
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
|
|
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
// Matches the real shape `request()` throws: a plain, human-readable
|
|
// `.message` plus a `.code` a caller actually branches on.
|
|
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
|
|
);
|
|
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
lanes: [{ ...LANE, run_id: "run-live" }],
|
|
});
|
|
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
id: "run-live",
|
|
laneId: 1,
|
|
status: "running",
|
|
cwd: "/workspace/a",
|
|
model: null,
|
|
permissionMode: null,
|
|
effort: null,
|
|
resumeSessionId: null,
|
|
sessionId: null,
|
|
startedAt: null,
|
|
promptPreview: null,
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter>
|
|
<LaneConsolePane {...baseProps()} />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
|
|
const resumeButtons = await screen.findAllByRole("button", {
|
|
name: "Resume existing session",
|
|
});
|
|
fireEvent.click(resumeButtons[0]!);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
|
|
);
|
|
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
|
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
|
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
|
|
const run = (id: string, laneId: number, promptPreview: string) => ({
|
|
id,
|
|
laneId,
|
|
status: "running",
|
|
cwd: "/workspace/x",
|
|
model: null,
|
|
permissionMode: null,
|
|
effort: null,
|
|
resumeSessionId: null,
|
|
sessionId: null,
|
|
startedAt: "2026-09-24T01:00:00Z",
|
|
promptPreview,
|
|
});
|
|
const session = (id: string, cwd: string, name: string) =>
|
|
({
|
|
id,
|
|
cwd,
|
|
name,
|
|
model: null,
|
|
source: "local",
|
|
started_at: "2026-09-24T01:00:00Z",
|
|
}) as never;
|
|
render(
|
|
<MemoryRouter>
|
|
<LaneConsolePane
|
|
{...baseProps()}
|
|
lanes={[
|
|
LANE,
|
|
{ ...LANE, id: 2, cwd: "/workspace/b" },
|
|
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
|
|
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
|
|
]}
|
|
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
|
|
externalSessions={[
|
|
session("s1", "/workspace/a/src", "my session"),
|
|
session("s2", "/workspace/b", "b session"),
|
|
session("s3", "/workspace/a/nested/x", "nested session"),
|
|
session("s4", "/workspace/ab", "sibling prefix"),
|
|
]}
|
|
/>
|
|
</MemoryRouter>
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
|
|
expect(screen.getByText("mine")).toBeTruthy();
|
|
expect(screen.getByText("my session")).toBeTruthy();
|
|
expect(screen.queryByText("other lane's")).toBeNull();
|
|
expect(screen.queryByText("b session")).toBeNull();
|
|
expect(screen.queryByText("nested session")).toBeNull();
|
|
expect(screen.queryByText("sibling prefix")).toBeNull();
|
|
});
|
|
});
|