0a1dcfeb7f
- 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>
1280 lines
49 KiB
TypeScript
1280 lines
49 KiB
TypeScript
/**
|
||
* @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");
|
||
});
|
||
});
|