feat(workspace): session view console as tabs, with New terminal
The 1/2/4 split view becomes a tab bar (ConsoleTabs.tsx): one tab per live tmux slot (0-3) of the workspace, the active one being ?run=, every live tab's pane kept mounted and hidden with CSS. The + menu opens a new tab in the first free slot: New terminal (a plain shell - shell: true on POST /api/lanes/:id/start, spawnShell, tool 'shell', no run_id/status change), any installed enabled AI tool started bare, or Settings -> Agents. A tab's x stops its session. splitViewStorage, &pane= and the sidebar's Ctrl/Cmd+click are gone.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Workspace.test.tsx
|
||||
* @description Workspace page integration tests covering the lane-based run flow.
|
||||
* Verifies: lanes API contract, the home vs session view, split panes, and
|
||||
* 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>
|
||||
*/
|
||||
@@ -56,6 +56,12 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||
WorkspaceHome: () => <div data-testid="workspace-home" />,
|
||||
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 [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||||
},
|
||||
}));
|
||||
// An ended session's replay (its own suite): a stub that names its session.
|
||||
vi.mock("../../components/lanes/SessionReplay", () => ({
|
||||
@@ -536,50 +542,66 @@ describe("Workspace — proof gallery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split terminal view", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
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,
|
||||
});
|
||||
|
||||
it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(1);
|
||||
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
||||
const original = vi.mocked(api.run.list).getMockImplementation();
|
||||
afterEach(() => {
|
||||
vi.mocked(api.run.list).mockImplementation(original!);
|
||||
});
|
||||
|
||||
it("switching to 2-pane layout renders two panes bound to the selected lane", async () => {
|
||||
await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||
await settle();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("switching to 4-pane layout renders four panes", async () => {
|
||||
await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
|
||||
await settle();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("persists the layout to localStorage across remounts", async () => {
|
||||
const { unmount } = await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||
await settle();
|
||||
unmount();
|
||||
|
||||
await renderWorkspace();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores a persisted per-pane lane id: every pane follows the selected lane", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.splitView",
|
||||
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
|
||||
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: 1, shell: true });
|
||||
});
|
||||
|
||||
it("disables + once all four 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")).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")
|
||||
);
|
||||
await renderWorkspace();
|
||||
expect(screen.queryAllByTestId("pane-empty")).toHaveLength(0);
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -638,9 +660,10 @@ describe("Workspace — home and session view", () => {
|
||||
});
|
||||
|
||||
it("has no run form in a pane: an empty pane's New session opens the home", async () => {
|
||||
// The run opens in pane 1, so pane 0 is left with nothing to show.
|
||||
// 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-1&pane=1"]}>
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -679,17 +702,4 @@ describe("Workspace — home and session view", () => {
|
||||
vi.mocked(api.run.list).mockImplementation(original!);
|
||||
localStorage.removeItem("agenthub.workspace.changesOpen");
|
||||
});
|
||||
|
||||
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
|
||||
localStorage.removeItem("agenthub.workspace.splitView");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1&pane=1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.workspace.splitView")!).layout).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user