6c605849d8
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
1089 lines
40 KiB
TypeScript
1089 lines
40 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 } from "vitest";
|
|
import { render, act, cleanup, 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}`,
|
|
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("../../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: {
|
|
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 router picker. Only the built-in profile, which is the
|
|
// shape that renders no picker — nothing here asserts on it.
|
|
aiRouters: {
|
|
list: vi.fn().mockImplementation(async () => {
|
|
recordCall("GET", "/api/ai-routers");
|
|
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/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";
|
|
|
|
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("opens the add-workspace modal from the empty banner when there is none", async () => {
|
|
const user = userEvent.setup();
|
|
lanesToReturn = [];
|
|
await renderWorkspace();
|
|
|
|
await user.click(screen.getByTestId("lane-empty"));
|
|
expect(
|
|
await screen.findByText("Create a workspace from a working directory")
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
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 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("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("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-1-2-3")).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("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");
|
|
});
|
|
});
|