feat(workspace): no double resume, server-picked tabs, terminal a11y
- A resume of a session a live pane already holds is refused (409 ESESSIONLIVE with error.run_id) and the client opens that pane, pasting any typed message there; catalog tools refuse resumeSessionId (EUNSUPPORTED) instead of silently starting fresh. - POST /api/lanes/:id/start takes slot: "auto": the server picks the free tab under the lane lock (components/lanes/startSession.ts on the client, kept out of WorkspaceHome so the sidebar does not pull react-markdown into the main bundle). - Unsaved whole-file edits are guarded on every exit (Back, another file, tab switch, close, lane/session change, beforeunload). - Closing an AI tab asks first; hiding a pane has its own icon. Tabs and pane headers show the session name, a state dot, rename by double-click. - Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F / Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52 clipboard, a Reconnecting overlay, debounced resizes, optional screen reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes. - Changes panel: draggable splitter, wrapping git bar, history paging, disarming discard/undo/abort, inline diff comments pasted only into a visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show more in the sidebar and Recents; idle dots readable with sr-only text. - Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
@@ -55,6 +55,8 @@ let nextRunId = 1;
|
||||
// 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" />,
|
||||
}));
|
||||
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 }>) => {
|
||||
@@ -63,6 +65,25 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||
);
|
||||
return [0, 1, 2, 3].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 = [0, 1, 2, 3].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", () => ({
|
||||
@@ -243,6 +264,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
},
|
||||
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: [] }),
|
||||
},
|
||||
},
|
||||
@@ -594,7 +618,7 @@ describe("console tabs", () => {
|
||||
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 });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||
});
|
||||
|
||||
it("disables + once all four slots are taken", async () => {
|
||||
@@ -782,12 +806,15 @@ describe("split panes", () => {
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(screen.getByTestId("pane-menu-split-bottom"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 3, shell: true });
|
||||
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("Close pane"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
|
||||
await right(screen.getByTestId("console-tab-2"));
|
||||
@@ -804,7 +831,7 @@ describe("split panes", () => {
|
||||
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: 2, shell: true });
|
||||
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 () => {
|
||||
@@ -815,7 +842,7 @@ describe("split panes", () => {
|
||||
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: 2, prompt: "" });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
|
||||
});
|
||||
|
||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||
|
||||
Reference in New Issue
Block a user