d397d3a1f6
- Split view: every pane shows the selected lane, each in its own tmux session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's Active runs hides what the other panes show; stop/reset/remove kill every slot. No per-pane lane picker any more. - Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js) with the read path's guards, existing text files only, and a baseSha stale check (409 ESTALE). A file it cannot take says why. - Per-hunk review bar under each hunk's last changed line: prev/next, "n of N", Undo, Keep. Review diffs no longer nest their own scroll box (unless windowed), so navigation moves the one real scroller. - Files tree lists untracked-not-ignored files and colours entries by git status (M/U/A/D/R), folders holding changes get a dot. - Changes panel: Unstage all on the single commit/Stage-all row; sizes Narrow (30%) / Medium / Full screen, no 70/30. - Lane card: the needs-you banner is gone (the strip tile keeps it).
457 lines
15 KiB
TypeScript
457 lines
15 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();
|
|
});
|
|
|
|
describe("split panes on one lane", () => {
|
|
const run = (id: string, promptPreview = id) => ({
|
|
id,
|
|
laneId: 1,
|
|
status: "running" as const,
|
|
cwd: "/workspace/a",
|
|
model: null,
|
|
permissionMode: null,
|
|
effort: null,
|
|
resumeSessionId: null,
|
|
sessionId: null,
|
|
startedAt: "2026-09-24T01:00:00Z",
|
|
promptPreview,
|
|
tool: "claude" as const,
|
|
});
|
|
|
|
it("attaches a pane only to its own slot's session", async () => {
|
|
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
|
|
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
|
"data-run-id",
|
|
"agenthub-lane-1-2"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("starts into its own slot and shows the session the server opened", async () => {
|
|
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
lane: { ...LANE, run_id: "agenthub-lane-1" },
|
|
run_id: "agenthub-lane-1-1",
|
|
});
|
|
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
|
|
render(<LaneConsolePane {...baseProps()} slot={1} />);
|
|
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.action).toHaveBeenCalledWith(
|
|
1,
|
|
"start",
|
|
expect.objectContaining({ slot: 1 })
|
|
)
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
|
"data-run-id",
|
|
"agenthub-lane-1-1"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("leaves runs other panes show out of Active runs", () => {
|
|
render(
|
|
<MemoryRouter>
|
|
<LaneConsolePane
|
|
{...baseProps()}
|
|
slot={1}
|
|
activeRuns={{
|
|
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
|
|
}}
|
|
hiddenRunIds={["agenthub-lane-1"]}
|
|
/>
|
|
</MemoryRouter>
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
|
|
expect(screen.getByText("free")).toBeTruthy();
|
|
expect(screen.queryByText("shown")).toBeNull();
|
|
});
|
|
});
|
|
});
|