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
+48 -9
View File
@@ -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;
}