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:
2026-10-05 13:22:12 +07:00
parent 6715fbd971
commit efa417488a
43 changed files with 2252 additions and 507 deletions
+63
View File
@@ -1926,3 +1926,66 @@ describe("a session's own worktree (claude --worktree)", () => {
assert.equal(r.body.error.code, "ENOWORKTREE");
});
});
// The server, not a stale client list, picks the tab and refuses a second pane
// on a session one already holds.
describe("start: slot auto and a session already live", () => {
const tmux = require("../lib/tmux");
let id;
let cwd;
const live = new Set();
before(async () => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-auto-"));
id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
tmux.__setExecImpl((args) => {
const name = String(args[args.indexOf("-t") + 1] || "").replace(/^=|:$/g, "");
if (args[0] === "has-session" && !live.has(name)) throw new Error("no such session");
if (args[0] === "new-session") live.add(args[args.indexOf("-s") + 1]);
if (args[0] === "list-sessions") return [...live].join("\n");
return "";
});
});
after(() => {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
});
it("takes the first free slot for slot: auto", async () => {
live.add(`agenthub-lane-${id}`);
live.add(`agenthub-lane-${id}-1`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
assert.equal(r.status, 200);
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
});
it("answers ERUNLIVE when every slot is busy", async () => {
live.add(`agenthub-lane-${id}-3`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ERUNLIVE");
live.delete(`agenthub-lane-${id}-3`);
live.delete(`agenthub-lane-${id}-2`);
});
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
const first = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
});
assert.equal(first.status, 200);
const again = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
});
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "ESESSIONLIVE");
assert.equal(again.body.error.run_id, first.body.run_id);
// A fork makes a new session, so it is not a duplicate.
const fork = await request("POST", `/api/lanes/${id}/start`, {
slot: "auto",
resumeSessionId: "live-sess-0001",
forkSession: true,
});
assert.equal(fork.status, 200);
});
});