Files
AgentHub/client/src/pages/__tests__/Workspace.test.tsx
T
nntrivi2001 3a8be55b55 feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
2026-10-12 13:06:04 +07:00

1201 lines
46 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("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();
});
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("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");
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");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
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");
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 its session)")
);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
});
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");
});
});