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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -596,6 +596,21 @@ describe("pty-run", () => {
|
||||
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
|
||||
});
|
||||
|
||||
// A catalog tool has no "resume THIS session" argv; starting it fresh under
|
||||
// a resume request would read as a resume that silently lost the history.
|
||||
it("refuses resumeSessionId for a catalog tool instead of starting fresh", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({
|
||||
laneId: 15,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
resumeSessionId: "abc12345",
|
||||
}),
|
||||
{ code: "EUNSUPPORTED" }
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
|
||||
for (const tool of ["claude", "codex", "antigravity"]) {
|
||||
assert.throws(
|
||||
|
||||
@@ -319,6 +319,11 @@ function spawnRun(args) {
|
||||
) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
// Only the hand-written launchers can resume a NAMED session; a catalog
|
||||
// tool would silently start fresh under a "resume".
|
||||
if (resumeSessionId && !entry.launcher) {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
|
||||
}
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
|
||||
+48
-9
@@ -1779,20 +1779,37 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
switch (action) {
|
||||
case "start": {
|
||||
const slot = body.slot === undefined ? 0 : body.slot;
|
||||
// `"auto"`: the first free tab, picked under the lane lock below — a
|
||||
// client's own pick comes from a list that may already be stale.
|
||||
const auto = body.slot === "auto";
|
||||
let slot = body.slot === undefined || auto ? 0 : body.slot;
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||||
});
|
||||
}
|
||||
const isLive = (laneId, s) =>
|
||||
runs.getRun(runs.runIdForLane(laneId, s))?.status === "running";
|
||||
// Under the lock: the free slot, or null when every tab is busy.
|
||||
const pickSlot = (laneId) => {
|
||||
if (!auto) return slot;
|
||||
for (let s = 0; s <= runs.MAX_PANE_SLOT; s++) if (!isLive(laneId, s)) return s;
|
||||
return null;
|
||||
};
|
||||
const allBusy = () =>
|
||||
res.status(409).json({
|
||||
error: { code: "ERUNLIVE", message: "every tab of this workspace is busy" },
|
||||
});
|
||||
// A Terminal tab: a plain shell in the slot. No agent is at work in
|
||||
// it, so the lane's run_id/status stay as they are.
|
||||
if (body.shell === true) {
|
||||
const shellOutcome = await withLaneLock(lane.id, async () => {
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
if (!current) return { missing: true };
|
||||
const id = runs.runIdForLane(current.id, slot);
|
||||
if (runs.getRun(id)?.status === "running") return { conflict: true };
|
||||
const picked = pickSlot(current.id);
|
||||
if (picked === null) return { allBusy: true };
|
||||
slot = picked;
|
||||
if (isLive(current.id, slot)) return { conflict: true };
|
||||
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
|
||||
return {};
|
||||
});
|
||||
@@ -1801,12 +1818,11 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
if (shellOutcome.allBusy) return allBusy();
|
||||
if (shellOutcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({
|
||||
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
|
||||
});
|
||||
return res.status(409).json({
|
||||
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1866,6 +1882,19 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// waited for the lock — the lock makes that race visible instead of
|
||||
// spawning a run for a lane that no longer exists.
|
||||
if (!current) return { missing: true };
|
||||
// A session holds ONE pane: a second `--resume` of it would run two
|
||||
// agents writing one transcript. Its pane is named instead. A fork
|
||||
// makes a new session, so it is no duplicate.
|
||||
const resumeTarget = body.resumeSessionId || (body.resume ? current.session_id : null);
|
||||
if (resumeTarget && body.forkSession !== true) {
|
||||
const holder = runs
|
||||
.listRuns()
|
||||
.find((r) => r.status === "running" && r.sessionId === resumeTarget);
|
||||
if (holder) return { sessionLive: holder.id };
|
||||
}
|
||||
const picked = pickSlot(current.id);
|
||||
if (picked === null) return { allBusy: true };
|
||||
slot = picked;
|
||||
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id;
|
||||
const live = liveId ? runs.getRun(liveId) : null;
|
||||
if (live && live.status === "running") {
|
||||
@@ -1907,10 +1936,20 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
if (outcome.sessionLive) {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: "ESESSIONLIVE",
|
||||
message: "that session is already open in a tab",
|
||||
run_id: outcome.sessionLive,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (outcome.allBusy) return allBusy();
|
||||
if (outcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "ERUNLIVE", message: "workspace already has a live run" } });
|
||||
.json({ error: { code: "ERUNLIVE", message: "that tab already has a live session" } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user