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:
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file startSession.ts — starting a session in a workspace's first free tab
|
||||
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
|
||||
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu
|
||||
* can start a session without pulling the whole home (and react-markdown) into
|
||||
* the main bundle.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
|
||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||
export function paneRunId(laneId: number, slot: number): string {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a session in the workspace's first free tab. The SERVER picks the slot
|
||||
* (`slot: "auto"`, under its lane lock), because any list the client holds may
|
||||
* already be stale. Resolves to the run id it opened — or, when the session to
|
||||
* resume is already open in a tab (`ESESSIONLIVE`), to that tab, so a second
|
||||
* `--resume` of one session never starts.
|
||||
*/
|
||||
export async function startInFreeTab(
|
||||
laneId: number,
|
||||
body: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
return (await startInFreeTabDetailed(laneId, body)).runId;
|
||||
}
|
||||
|
||||
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
|
||||
export function laneOfRun(runId: string): number | null {
|
||||
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** `startInFreeTab`, saying also whether the run was STARTED or an existing
|
||||
* pane already held the session (`held` — nothing was sent to it), and the
|
||||
* workspace that pane belongs to (a nested folder's workspace may hold it). */
|
||||
export async function startInFreeTabDetailed(
|
||||
laneId: number,
|
||||
body: Record<string, unknown>
|
||||
): Promise<{ runId: string; laneId: number; held: boolean }> {
|
||||
try {
|
||||
const r = await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
|
||||
return { runId: r.run_id ?? paneRunId(laneId, 0), laneId, held: false };
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; runId?: string };
|
||||
if (e.code === "ESESSIONLIVE" && e.runId)
|
||||
return { runId: e.runId, laneId: laneOfRun(e.runId) ?? laneId, held: true };
|
||||
if (e.code === "ERUNLIVE") throw new Error(i18n.t("lanes:home.allBusy"));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The first pane slot with no live run, or null when all four are busy. */
|
||||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
);
|
||||
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user