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:
@@ -3,8 +3,8 @@
|
||||
* @description The transcript page's explicit Resume and Fork: it shows only for a
|
||||
* Claude session inside a workspace, a live session offers its pane instead,
|
||||
* and a resume asks first (it re-reads the whole transcript), then starts in
|
||||
* the first free pane slot and opens that pane — or refuses when all four are
|
||||
* busy; a Fork asks too and starts a new session id beside a live original.
|
||||
* the free pane slot the server picks (`slot: "auto"`) and opens that pane —
|
||||
* or says so when all four are busy; a Fork asks too and starts a new session id beside a live original.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript");
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-1",
|
||||
});
|
||||
await waitFor(() =>
|
||||
@@ -97,7 +97,7 @@ describe("SessionResumeButton", () => {
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(4, "start", {
|
||||
slot: 0,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-2",
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
|
||||
await user.click(screen.getByRole("button", { name: "Fork session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-1",
|
||||
forkSession: true,
|
||||
});
|
||||
@@ -147,7 +147,7 @@ describe("SessionResumeButton", () => {
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-wt",
|
||||
});
|
||||
});
|
||||
@@ -178,7 +178,12 @@ describe("SessionResumeButton", () => {
|
||||
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: 2, resumeSessionId: "s-1" });
|
||||
// The server picks the free tab under its lock; the client only re-reads
|
||||
// to open a pane that took this session meanwhile.
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts once on a double click while the panes are being re-read", async () => {
|
||||
@@ -216,16 +221,31 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
|
||||
});
|
||||
|
||||
it("refuses when all four panes are busy", async () => {
|
||||
runs = [0, 1, 2, 3].map(
|
||||
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
|
||||
it("says so when the server finds all four panes busy", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
|
||||
});
|
||||
|
||||
it("opens the pane the server names when the session is already live there", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
// A nested workspace's pane holds it: opened in THAT workspace.
|
||||
Object.assign(new Error("open"), { code: "ESESSIONLIVE", runId: "agenthub-lane-4-3" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("where").textContent).toBe(
|
||||
"/workspace?lane=4&run=agenthub-lane-4-3"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows nothing outside a workspace or for a non-Claude session", async () => {
|
||||
|
||||
Reference in New Issue
Block a user