Files
AgentHub/client/src/pages/__tests__/Workspace.test.tsx
T
nntrivi2001 0a1dcfeb7f feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix
- Stop (a tab's or pane's X, Close tab) now archives the session it held
  (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in
  the tab, pane and session right-click menus) stops the pane but keeps the
  session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts.
- Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS
  CodeCommit takes only RSA), every ssh git offers both, and a member's
  ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them.
- Update dialog: Auto fix starts a Claude Code session on the install
  folder with the failed update's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00

1280 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Workspace.test.tsx
* @description Workspace page integration tests covering the lane-based run flow.
* Verifies: lanes API contract, the home vs session view, the console tabs, and
* that a pane has no run form of its own (a new session starts from the home).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from "vitest";
import {
render,
act,
cleanup,
createEvent,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint
const recordedCalls: Array<{ method: string; path: string }> = [];
// Mutable test state. `run_id` is widened explicitly: the fixtures start with
// no run, and one case assigns run ids to drive the console.
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
active_feature_id?: number | null;
tool?: string;
};
let lanesToReturn: LaneFixture[] = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
let nextRunId = 1;
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
}));
// Hoisted with the mock: a workspace holds ten sessions (MAX_PANE_SLOT 9).
const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
laneOfRun: (runId: string) => {
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
return m ? Number(m[1]) : null;
},
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return SLOTS.find((s) => !busy.has(s)) ?? null;
},
// The server picks the slot (`slot: "auto"`); stand in for it with the first
// one the mocked run list leaves free.
startInFreeTab: async (laneId: number, body: Record<string, unknown>) => {
const { api } = await import("../../lib/api");
const { items } = await api.run.list();
const busy = new Set(
(items as Array<{ laneId: number; slot?: number; status: string }>)
.filter((r) => r.laneId === laneId && r.status === "running")
.map((r) => r.slot ?? 0)
);
const slot = SLOTS.find((x) => !busy.has(x)) ?? 0;
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
},
}));
// Closing an AI tab asks first (the themed confirm needs App's host): yes.
vi.mock("../../lib/confirm", () => ({
confirmDialog: vi.fn(async () => true),
confirmDiscard: vi.fn(async () => true),
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../SettingsHub", () => ({
SettingsHub: ({ section }: { section?: string }) => (
<div data-testid="settings-hub">{section}</div>
),
}));
vi.mock("../../components/lanes/SessionReplay", () => ({
SessionReplay: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-replay">{sessionId}</div>
),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
recordedCalls.push({ method, path });
};
return {
...actual,
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
lanes: {
// The Changes panel's folder picker reads the workspace's extra folders.
sources: vi.fn().mockResolvedValue({ sources: [] }),
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn };
}),
ensure: vi.fn().mockImplementation(async ({ cwd }: { cwd: string }) => {
recordCall("POST", "/api/lanes/ensure");
const newLane = {
id: Math.max(...lanesToReturn.map((l) => l.id), 0) + 1,
cwd,
title: `Lane for ${cwd}`,
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
};
lanesToReturn = [...lanesToReturn, newLane];
return { lane: newLane, created: true };
}),
action: vi.fn().mockImplementation(async (id: number, action: string) => {
recordCall("POST", `/api/lanes/${id}/${action}`);
if (action === "start") {
const runId = `run-${nextRunId++}`;
const lane = lanesToReturn.find((l) => l.id === id);
if (lane) {
lane.run_id = runId;
}
return { lane: { ...lane, run_id: runId } };
}
return { ok: true };
}),
// LaneCard reads its own working-copy facts per card. Nothing here
// asserts on them, so report the "not a git repo" shape.
git: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/git`);
return { available: false };
}),
// Same for the lane's own application stack: nothing here asserts on it,
// so report the "no .agenthub/profile" shape, which is the common case.
runtime: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/runtime`);
return { available: false };
}),
stage: vi.fn().mockImplementation(async () => {
recordCall("POST", `/api/lanes/stage`);
return { ok: true };
}),
pipelines: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes/pipelines");
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
}),
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
recordCall("PATCH", `/api/lanes/${id}`);
const lane = lanesToReturn.find((l) => l.id === id);
return { lane: { ...lane, ...patch } };
}),
features: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/features`);
return { features: [] };
}),
show: vi.fn().mockImplementation(async (id: number, slug: string) => {
recordCall("GET", `/api/lanes/${id}/features/${slug}`);
return { feature: null };
}),
},
proof: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/proof`);
return { features: [] };
}),
imageUrl: vi
.fn()
.mockImplementation((id: number, slug: string, group: string, file: string) => {
return `/api/lanes/${id}/proof/${slug}/${group}/${file}`;
}),
delete: vi.fn().mockImplementation(async (id: number, slug: string) => {
recordCall("DELETE", `/api/lanes/${id}/proof/${slug}`);
return { deleted: 0 };
}),
},
},
// The lane card's proxy picker. Only the built-in profile, which is the
// shape that renders no picker — nothing here asserts on it.
aiProxies: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/ai-proxies");
return { items: [] };
}),
},
run: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/list");
return { runs: [], items: [] };
}),
binary: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/binary");
return { found: true, path: "/usr/bin/claude" };
}),
cwds: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/cwds");
return { items: [{ kind: "home", path: "/home/user", label: "Home" }] };
}),
files: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/files");
return { items: [] };
}),
start: vi.fn().mockImplementation(async () => {
recordCall("POST", "/api/run/start");
const runId = `run-${nextRunId++}`;
return { id: runId, status: "running" };
}),
get: vi.fn().mockImplementation(async (id: string) => {
recordCall("GET", `/api/run/${id}`);
return {
id,
pid: 12345,
mode: "conversation",
cwd: "/workspace",
model: "claude-opus-5",
permissionMode: "acceptEdits",
effort: "medium",
prompt: "test prompt",
argv: [],
resumeSessionId: null,
status: "running",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
sessionId: "sess-1",
envelopeCount: 0,
stdoutTail: "",
stderrTail: "",
messages: [],
envelopes: [],
};
}),
send: vi.fn().mockImplementation(async (id: string) => {
recordCall("POST", `/api/run/${id}/send`);
return { messageId: "msg-1" };
}),
kill: vi.fn().mockImplementation(async (id: string) => {
recordCall("POST", `/api/run/${id}/kill`);
return { ok: true };
}),
},
ccConfig: {
commands: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/cc-config/commands");
return { items: [] };
}),
plugins: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/cc-config/plugins");
return { plugins: [] };
}),
file: vi.fn().mockImplementation(async (path: string) => {
recordCall("GET", `/api/cc-config/file?path=${path}`);
return { text: "", content: "" };
}),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
get: vi.fn().mockImplementation(async (id: string) => ({
session: { id, cwd: "/workspace/a", tool: "claude" },
})),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/PaneChat", () => ({
PaneChat: () => <div data-testid="pane-chat" />,
}));
vi.mock("../../components/run/TerminalView", () => ({
// `exit-<runId>` stands in for the server's exit frame.
TerminalView: ({
runId,
onExit,
autoFocus,
}: {
runId: string;
onExit?: () => void;
autoFocus?: boolean;
}) => (
<div
data-testid="terminal-view"
data-run-id={runId}
data-autofocus={autoFocus === false ? "false" : "true"}
>
<button data-testid={`exit-${runId}`} onClick={() => onExit?.()} />
</div>
),
}));
import { Workspace } from "../Workspace";
import { api } from "../../lib/api";
import { setSessionChat } from "../../lib/chatUi";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
// Set up DOM polyfills for tests
if (typeof globalThis !== "undefined") {
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
if (typeof Element !== "undefined") {
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
function Picker() {
const [params, setParams] = useSearchParams();
return (
<>
<span data-testid="lane-param">{params.get("lane")}</span>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`pick-${id}`}
onClick={() => setParams({ lane: String(id) })}
/>
))}
</>
);
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
beforeEach(() => {
recordedCalls.length = 0;
// The selected workspace is remembered in localStorage; each test starts on the first.
window.localStorage.clear();
lanesToReturn = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
nextRunId = 1;
vi.clearAllMocks();
});
afterEach(() => {
recordedCalls.length = 0;
vi.clearAllMocks();
});
describe("Workspace page — lane integration", () => {
it("has no lane strip: it opens the first workspace and names it in the URL", async () => {
await renderWorkspace();
expect(screen.queryByTestId("lane-strip")).toBeNull();
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-param").textContent).toBe("1");
});
it("selecting a lane switches the pipeline and run id", async () => {
lanesToReturn = lanesToReturn.map((l, i) => ({
...l,
run_id: i === 0 ? "run-100" : "run-200",
}));
await renderWorkspace();
const user = userEvent.setup();
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
// The sidebar names the workspace in the URL; the page follows it.
await user.click(screen.getByTestId("pick-2"));
await settle();
// Only the selected lane gets a full card.
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
});
describe("Workspace layout", () => {
it("asks the user to upgrade a tmux older than their platform needs", async () => {
const { toast } = await import("../../lib/toast");
const shown = vi.spyOn(toast, "error");
const run = api.run as unknown as Record<string, unknown>;
run.tmuxAvailable = vi.fn().mockResolvedValue({
available: true,
version: "3.3.2",
upgrade: "psmux 3.3.2 is older than 3.4.0 — winget upgrade marlocarlo.psmux",
});
onTestFinished(() => {
delete run.tmuxAvailable;
shown.mockRestore();
});
await renderWorkspace();
expect(shown).toHaveBeenCalledWith(expect.stringContaining("winget upgrade marlocarlo.psmux"));
});
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
lanesToReturn = [];
await renderWorkspace();
expect(screen.queryByTestId("lane-empty")).toBeNull();
});
it("shows the full card only for the selected lane, in the detail panel", async () => {
await renderWorkspace();
// Lane 1 is selected by default. Its full card — controls, git facts — is
// the detail panel; the other lane stays a tile.
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-2")).toBeNull();
});
it("renders the selected lane's pipeline detail panel", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-detail")).toBeInTheDocument();
// The colour legend was removed: read once, noise thereafter.
expect(screen.queryByTestId("pipeline-legend")).toBeNull();
});
it("shows a feature picker when features are available", async () => {
await renderWorkspace();
// The feature picker should only render when there are features available
// With the current mock setup, features return empty list, so picker won't render
let picker = screen.queryByTestId("feature-picker");
expect(picker).toBeNull();
// This test verifies the feature picker UI was added and the i18n keys exist
// Full feature testing requires server-side mocking of feature lists
});
it("renders the console body without any collapse toggle", async () => {
await renderWorkspace();
// The disclosure was removed - the console is always attached and visible.
expect(screen.queryByTestId("console-toggle")).toBeNull();
const body = screen.getByTestId("console-body");
expect(body).toBeInTheDocument();
expect(body.className).not.toContain("hidden");
});
});
describe("Workspace — the console is its own section", () => {
it("does not nest the console inside any lane card", async () => {
await renderWorkspace();
const body = screen.getByTestId("console-body");
// The console is a window onto a process, not a property of a card. Nesting
// it in one made the owning card span the row and left a hole when closed.
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
});
it("has no header line of its own - the pipeline panel already names the lane", async () => {
lanesToReturn = lanesToReturn.map((l, i) => ({ ...l, run_id: i === 0 ? "run-100" : null }));
await renderWorkspace();
expect(screen.queryByTestId("console-owner")).toBeNull();
expect(screen.queryByText("Claude console")).toBeNull();
});
it("keeps the console reachable when no lane exists at all", async () => {
lanesToReturn = [];
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
await renderWorkspace();
expect(screen.getByTestId("console-body")).toBeInTheDocument();
});
it("attaches under the selected lane's pipeline, not as a separate section", async () => {
await renderWorkspace();
const detail = screen.getByTestId("lane-detail");
const body = screen.getByTestId("console-body");
expect(detail.contains(body)).toBe(true);
});
// A SECOND render site for the console is what let `currentLane` flipping
// (lanes arriving on mount, the selected lane removed, a refetch re-picking
// the selection) unmount the terminal and wipe a half-typed prompt. It must
// sit inside `lane-detail` in BOTH states, so React never has to move it.
it("keeps the console at one render site whether or not a lane is selected", async () => {
lanesToReturn = [];
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
await renderWorkspace();
const detail = screen.getByTestId("lane-detail");
expect(detail.contains(screen.getByTestId("console-body"))).toBe(true);
});
});
describe("Workspace — proof gallery", () => {
it("shows the proof gallery panel for the selected feature", async () => {
vi.mocked(api.lanes.proof.list).mockResolvedValue({
features: [
{
slug: "feat-one",
groups: { "qc-local": ["a.png", "b.png"] },
ticket_report: "",
mtime: 0,
},
],
});
// Set active_feature_id and ensure features list is populated
// (indexed access is `| undefined` under noUncheckedIndexedAccess)
lanesToReturn[0]!.active_feature_id = 1;
vi.mocked(api.lanes.features.list).mockResolvedValue({
features: [
{
id: 1,
lane_id: 1,
slug: "feat-one",
title: "Feature One",
stage: "ship",
status: "running",
archived_at: null,
pipeline_nodes: [],
progress: 100,
},
],
});
await renderWorkspace();
await waitFor(() => {
expect(screen.getByTestId("proof-gallery")).toBeInTheDocument();
});
});
it("shows no gallery panel when the selected feature has no proof", async () => {
vi.mocked(api.lanes.proof.list).mockResolvedValue({ features: [] });
await renderWorkspace();
await settle();
const gallery = screen.queryByTestId("proof-gallery");
expect(gallery).toBeNull();
});
});
describe("console tabs", () => {
const live = (slot: number, tool = "claude") => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
tool,
model: null,
promptPreview: null,
});
const original = vi.mocked(api.run.list).getMockImplementation();
afterEach(() => {
vi.mocked(api.run.list).mockImplementation(original!);
});
const withRuns = (items: ReturnType<typeof live>[]) =>
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
const open = async (url: string) => {
render(
<MemoryRouter initialEntries={[url]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
};
it("shows a tab per live session and keeps every tab's pane mounted, showing only the active one", async () => {
withRuns([live(0), live(2, "shell")]);
await open("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("console-tab-0")).toHaveAttribute("aria-selected", "false");
expect(screen.getByTestId("console-tab-2")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-2")).toHaveTextContent("Terminal");
const bodies = screen.getAllByTestId("console-body");
expect(bodies).toHaveLength(2);
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
});
it("opens Settings as a tab of its own, the console only hidden, kept until its X", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1&settings=agents");
const user = userEvent.setup();
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// A session tab shows its panes again; the Settings tab stays, hidden.
await user.click(screen.getByTestId("console-tab-0"));
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
expect(screen.getByTestId("workspace-settings")).toHaveClass("hidden");
expect(screen.getByTestId("console-tab-settings")).toHaveAttribute("aria-selected", "false");
// Back on it: the section it was on.
await user.click(screen.getByTestId("console-tab-settings"));
expect(screen.getByTestId("workspace-settings")).not.toHaveClass("hidden");
expect(screen.getByTestId("settings-hub")).toHaveTextContent("agents");
await user.click(screen.getByTestId("console-tab-settings-close"));
expect(screen.queryByTestId("console-tab-settings")).toBeNull();
expect(screen.queryByTestId("settings-hub")).toBeNull();
expect(screen.getByTestId("console-body").closest(".hidden")).toBeNull();
});
it("opens a new terminal in the first free slot from the + menu", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
const user = userEvent.setup();
await user.click(screen.getByTestId("console-tab-new"));
await user.click(within(screen.getByTestId("console-tab-menu")).getByText("New terminal"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("keeps + enabled past four sessions and disables it once all ten slots are taken", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeEnabled();
cleanup();
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
// Switching workspaces used to hand slot 0 of the new workspace the SAME
// pane as the old one's slot 0: its terminal was disposed and a new one
// attached, replaying the whole screen — the lag of every switch.
it("keeps a workspace's terminals mounted (hidden) while another workspace is shown", async () => {
withRuns([live(0), { ...live(0), id: "agenthub-lane-2", laneId: 2, slot: 0 }]);
function Go() {
const [, setParams] = useSearchParams();
return (
<>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`go-${id}`}
onClick={() => setParams({ lane: String(id), run: `agenthub-lane-${id}` })}
/>
))}
</>
);
}
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Go />
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
const term = (id: string) =>
screen.getAllByTestId("terminal-view").find((el) => el.dataset.runId === id);
const first = term("agenthub-lane-1");
expect(first).toBeDefined();
const user = userEvent.setup();
await user.click(screen.getByTestId("go-2"));
await settle();
const second = term("agenthub-lane-2");
expect(second?.closest(".hidden")).toBeNull();
// Lane 1's terminal is the same element, only hidden.
expect(term("agenthub-lane-1")).toBe(first);
expect(first!.closest(".hidden")).not.toBeNull();
await user.click(screen.getByTestId("go-1"));
await settle();
expect(term("agenthub-lane-1")).toBe(first);
expect(first!.closest(".hidden")).toBeNull();
expect(term("agenthub-lane-2")).toBe(second);
// Only the shown workspace's tabs are in the tab bar.
expect(
screen.getAllByRole("tab").filter((t) => t.dataset.testid?.startsWith("console-tab-"))
).toHaveLength(1);
});
it("with tabs from every workspace on, shows another workspace's session and selects it on click", async () => {
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
function LaneParam() {
const [params] = useSearchParams();
return <span data-testid="lane-param">{params.get("lane")}</span>;
}
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
<LaneParam />
</MemoryRouter>
);
await settle();
await settle();
// Leaves are `paneKey`s: lane × 10 + slot.
expect(screen.getByTestId("console-tab-10")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-21")).toHaveTextContent("Lane B");
await userEvent.setup().click(screen.getByTestId("console-tab-21"));
await waitFor(() => expect(screen.getByTestId("lane-param")).toHaveTextContent("2"));
expect(screen.getByTestId("console-tab-21")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
});
it("a tab's moon sleeps its session: stopped but not archived", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-sleep-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: false });
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
});
it("closing the active tab stops its session and opens the next one", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-close-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
});
});
describe("split panes", () => {
const live = (slot: number, tool = "claude") => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
tool,
model: null,
promptPreview: null,
});
const original = vi.mocked(api.run.list).getMockImplementation();
afterEach(() => {
vi.mocked(api.run.list).mockImplementation(original!);
});
const withRuns = (items: ReturnType<typeof live>[]) =>
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
/** The URL's `?run=` — the focused pane's run. */
function RunParam() {
const [params] = useSearchParams();
return <span data-testid="run-param">{params.get("run")}</span>;
}
const open = async (entry: string | { pathname: string; search: string; state?: unknown }) => {
render(
<MemoryRouter initialEntries={[entry]}>
<RunParam />
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
};
/** The slots a pane is drawn for (the rest are mounted and hidden). */
const shownPanes = () =>
screen
.getAllByTestId(/^pane-\d$/)
.filter((p) => !p.classList.contains("hidden"))
.map((p) => p.dataset.testid);
const ctrlClick = async (el: HTMLElement) => {
const user = userEvent.setup();
await user.keyboard("{Control>}");
await user.click(el);
await user.keyboard("{/Control}");
await settle();
};
const stored = () =>
JSON.parse(window.localStorage.getItem("agenthub.workspace.paneLayout") ?? "{}")["1"];
it("Ctrl+click on a tab brings it into the tab on screen as a split, one combo tab", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-0"]);
expect(screen.queryByTestId("pane-header-0")).toBeNull();
await ctrlClick(screen.getByTestId("console-tab-1"));
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
expect(screen.getByTestId("pane-1")).toHaveAttribute("data-focused", "true");
expect(screen.getByTestId("pane-header-0")).toBeInTheDocument();
// The two tabs became ONE tab holding both panes.
expect(screen.queryByTestId("console-tab-1")).toBeNull();
expect(screen.getByTestId("console-tab-0-1")).toHaveAttribute("aria-selected", "true");
expect(stored().groups).toHaveLength(1);
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
});
it("keeps each session's chat view when two tabs join into one combo", async () => {
setSessionChat("sess-0", true);
setSessionChat("sess-1", true);
const items = [
{ ...live(0), sessionId: "sess-0" },
{ ...live(1), sessionId: "sess-1" },
];
withRuns(items as never);
// As the server does: GET /api/run/:id is the list's own handle.
const get = vi.mocked(api.run.get).getMockImplementation()!;
onTestFinished(() => void vi.mocked(api.run.get).mockImplementation(get));
vi.mocked(api.run.get).mockImplementation(
async (id) => items.find((r) => r.id === id) as never
);
await open("/workspace?lane=1&run=agenthub-lane-1");
// Tab b dragged onto the middle of tab a (jsdom: every rect is 0 wide).
const drop = createEvent.drop(screen.getByTestId("console-tab-0"), {
dataTransfer: {
types: ["application/x-agenthub-tab"],
getData: (t: string) => (t === "application/x-agenthub-tab" ? "1" : ""),
},
});
Object.defineProperty(drop, "clientX", { value: 0 });
fireEvent(screen.getByTestId("console-tab-0"), drop);
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
await settle();
for (const s of [0, 1])
expect(within(screen.getByTestId(`pane-${s}`)).getByTestId("pane-chat")).toBeInTheDocument();
});
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("console-tab-2"));
await settle();
expect(shownPanes()).toEqual(["pane-2"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-2");
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("a tab holds at most four panes; a fifth split opens a tab of its own", async () => {
withRuns([live(0), live(1), live(2), live(3), live(4)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
for (const s of [1, 2, 3]) await ctrlClick(screen.getByTestId(`console-tab-${s}`));
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
await ctrlClick(screen.getByTestId("console-tab-4"));
expect(shownPanes()).toEqual(["pane-4"]);
expect(screen.getByTestId("console-tab-0-3-1-2")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
});
it("closing a pane takes it out into a tab of its own; closing a tab stops every session in it", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await ctrlClick(screen.getByTestId("console-tab-2"));
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2"]);
await userEvent.setup().click(screen.getByTestId("pane-close-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
expect(api.run.kill).not.toHaveBeenCalled();
expect(screen.getByTestId("console-tab-1")).toBeInTheDocument();
withRuns([live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
await settle();
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(shownPanes()).toEqual(["pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("a pane's X stops just its session, without taking it out of the tab first", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await ctrlClick(screen.getByTestId("console-tab-2"));
withRuns([live(0), live(2)]);
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
await settle();
expect(api.run.kill).toHaveBeenCalledTimes(1);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1", { archive: true });
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
expect(screen.queryByTestId("console-tab-1")).toBeNull();
});
it("drops a remembered pane whose session is gone", async () => {
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({
1: {
root: {
type: "split",
dir: "row",
ratio: 0.5,
a: { type: "pane", slot: 0 },
b: { type: "pane", slot: 3 },
},
focus: 3,
},
})
);
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-0"]);
});
it("a sidebar Ctrl/Cmd+click (router state) splits; a plain one goes into the focused pane", async () => {
withRuns([live(0), live(1)]);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 1: { root: { type: "pane", slot: 0 }, focus: 0 } })
);
await open({
pathname: "/workspace",
search: "?lane=1&run=agenthub-lane-1-1",
state: { split: true },
});
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
});
it("a pane's picker swaps two shown panes", async () => {
withRuns([live(0), live(1, "shell")]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
expect(screen.getByTestId("pane-0").style.left).toBe("0%");
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-pick-0"));
await user.click(screen.getByRole("menuitemradio", { name: /Terminal/ }));
await settle();
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
});
it("right-click on a tab opens it in a split or closes it; on a pane header, swaps, splits or closes", async () => {
withRuns([live(0), live(1, "shell"), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
const user = userEvent.setup();
const right = (target: Element) => user.pointer({ keys: "[MouseRight]", target });
await right(screen.getByTestId("console-tab-1"));
await user.click(within(screen.getByTestId("console-tab-menu-1")).getByText("Open in split"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
await right(screen.getByTestId("pane-header-0"));
await user.click(within(screen.getByTestId("pane-menu-0")).getByText(/Show Terminal here/));
await settle();
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
await right(screen.getByTestId("pane-header-1"));
await user.click(screen.getByTestId("pane-menu-split-bottom"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
// The new terminal (the slot the server picked, 3) opened under pane 1.
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-3"]);
await right(screen.getByTestId("pane-header-1"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Move to own tab"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
expect(api.run.kill).not.toHaveBeenCalled();
await right(screen.getByTestId("console-tab-2"));
await user.click(
within(screen.getByTestId("console-tab-menu-2")).getByText(
"Close tab (stops and archives its session)"
)
);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
});
it("a split menu starts a new terminal in the first free slot", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-bottom-1"));
await user.click(screen.getByTestId("pane-split-new-bottom-1"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("a split menu starts an AI tool, bare, in the first free slot", async () => {
lanesToReturn = lanesToReturn.map((l) => ({ ...l, tool: "claude" }));
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
});
it("with one pane, the split menus sit beside Tools and split it", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-0"]);
expect(screen.queryByTestId("pane-header-0")).toBeNull();
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-0"));
await user.click(screen.getAllByRole("menuitem")[0] as HTMLElement);
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
// Split: the tab bar's copy goes, each pane header has its own.
expect(screen.getAllByTestId("pane-split-right-0")).toHaveLength(1);
expect(screen.getByTestId("pane-header-0")).toContainElement(
screen.getByTestId("pane-split-right-0")
);
});
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
withRuns([live(0)]);
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
await settle();
expect(shownPanes()).toEqual(["pane-0"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
});
it("a run ending while the home is shown closes its pane but never leaves the home", async () => {
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({
1: {
root: {
type: "split",
dir: "row",
ratio: 0.5,
a: { type: "pane", slot: 0 },
b: { type: "pane", slot: 1 },
},
focus: 1,
},
})
);
withRuns([live(0), live(1)]);
await open("/workspace?lane=1");
expect(screen.getByTestId("run-param").textContent).toBe("");
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
await settle();
expect(screen.getByTestId("run-param").textContent).toBe("");
expect(stored().groups[0].root).toEqual({ type: "pane", slot: 0 });
// The last one too: still the home.
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
await settle();
expect(screen.getByTestId("run-param").textContent).toBe("");
});
it("an empty run-list read after the first does not wipe a split", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
withRuns([]);
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
});
it("only the focused pane's terminal takes the keyboard when it appears", async () => {
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({
1: {
root: {
type: "split",
dir: "row",
ratio: 0.5,
a: { type: "pane", slot: 0 },
b: { type: "pane", slot: 1 },
},
focus: 1,
},
})
);
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1-1");
const term = (id: string) =>
screen.getAllByTestId("terminal-view").find((t) => t.dataset.runId === id);
expect(term("agenthub-lane-1-1")).toHaveAttribute("data-autofocus", "true");
expect(term("agenthub-lane-1")).toHaveAttribute("data-autofocus", "false");
});
it("offers no new terminal in a split once all ten slots are busy", async () => {
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("pane-split-right-1"));
expect(screen.getByTestId("pane-split-new-right-1")).toBeDisabled();
});
it("the tab bar's Home button opens the workspace home, keeping the tabs", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-home"));
await settle();
expect(screen.getByTestId("run-param").textContent).toBe("");
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
expect(api.run.kill).not.toHaveBeenCalled();
});
it("a divider resizes by keyboard", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
const sep = screen.getByRole("separator");
sep.focus();
await userEvent.setup().keyboard("{ArrowRight}");
await settle();
expect(screen.getByTestId("pane-0").style.width).toBe("55.00000000000001%");
});
});
describe("Workspace — home and session view", () => {
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
await renderWorkspace();
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
});
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
// No header row (status, back arrow, name) and no pane header: the tab bar is it.
expect(screen.queryByTestId("workspace-back-home")).toBeNull();
expect(screen.queryByText(/^Run /)).toBeNull();
expect(screen.getByTestId("console-tab-new")).toBeInTheDocument();
});
it("opens an ended session's replay for ?session=, hiding (not unmounting) the console", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&session=s-ended"]}>
<Workspace />
<Routes>
<Route path="/sessions/:id" element={<div data-testid="analyst-page" />} />
<Route path="*" element={null} />
</Routes>
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("session-replay")).toHaveTextContent("s-ended");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// Analyst, beside Files and Changes, opens the session's analysis page.
await userEvent.setup().click(screen.getByTestId("panel-analyst"));
expect(await screen.findByTestId("analyst-page")).toBeInTheDocument();
});
it("has no Analyst target on a pane with no session yet", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.getByTestId("panel-analyst")).toBeDisabled();
});
it("has no run form in a pane: an empty pane's New session opens the home", async () => {
// Nothing live in slot 0: its pane is empty.
vi.mocked(api.run.get).mockRejectedValueOnce(new Error("gone"));
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByPlaceholderText(/absolute path/)).toBeNull();
const empty = screen.getByTestId("pane-no-run");
await userEvent.setup().click(within(empty).getByRole("button", { name: "New session" }));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
expect(recordedCalls.some((c) => c.path.includes("/start"))).toBe(false);
});
it("points the Changes panel at the opened run's worktree session", async () => {
localStorage.setItem("agenthub.workspace.changesOpen", "1");
const original = vi.mocked(api.run.list).getMockImplementation();
vi.mocked(api.run.list).mockResolvedValue({
runs: [],
items: [
{
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
sessionId: "s-wt",
worktree: true,
},
],
} as never);
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, session: "s-wt" }));
vi.mocked(api.run.list).mockImplementation(original!);
localStorage.removeItem("agenthub.workspace.changesOpen");
});
});