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:
@@ -2,7 +2,8 @@
|
||||
* @file panePaste.test.ts
|
||||
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||
* not lane 12), and nowhere once the pane unmounted.
|
||||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||||
* is not on screen (a hidden console behind a full-width Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,7 +17,7 @@ describe("panePaste", () => {
|
||||
const off12 = reg("agenthub-lane-12");
|
||||
const off0 = reg("agenthub-lane-1");
|
||||
const off2 = reg("agenthub-lane-1-2");
|
||||
expect(pasteToLane(1, "a")).toBe(true);
|
||||
expect(pasteToLane(1, "a")).toBe("agenthub-lane-1");
|
||||
expect(got["agenthub-lane-1"]).toEqual(["a"]);
|
||||
notePaneFocus("agenthub-lane-1-2");
|
||||
pasteToLane(1, "b");
|
||||
@@ -27,7 +28,24 @@ describe("panePaste", () => {
|
||||
expect(got["agenthub-lane-12"]).toBeUndefined();
|
||||
off0();
|
||||
off2();
|
||||
expect(pasteToLane(1, "d")).toBe(false);
|
||||
expect(pasteToLane(1, "d")).toBeNull();
|
||||
off12();
|
||||
});
|
||||
|
||||
it("never pastes into a pane that is not visible", () => {
|
||||
const got: string[] = [];
|
||||
let shown = false;
|
||||
const off = registerPane(
|
||||
"agenthub-lane-5",
|
||||
(t) => got.push(t),
|
||||
() => shown
|
||||
);
|
||||
notePaneFocus("agenthub-lane-5");
|
||||
expect(pasteToLane(5, "x")).toBeNull();
|
||||
expect(got).toEqual([]);
|
||||
shown = true;
|
||||
expect(pasteToLane(5, "y")).toBe("agenthub-lane-5");
|
||||
expect(got).toEqual(["y"]);
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
+34
-14
@@ -4,18 +4,30 @@
|
||||
* mounted TerminalView registers its run id; the text goes to the pane of that
|
||||
* workspace the user focused last, else any of its panes. It is a paste —
|
||||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||||
* Enter: the user reads it and sends it.
|
||||
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
|
||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||
* unseen); the caller says where it went, or that it went nowhere.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const panes = new Map<string, (text: string) => void>();
|
||||
interface Pane {
|
||||
paste: (text: string) => void;
|
||||
visible: () => boolean;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal; returns its unregister. */
|
||||
export function registerPane(runId: string, paste: (text: string) => void): () => void {
|
||||
panes.set(runId, paste);
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
|
||||
* unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string) => void,
|
||||
visible: () => boolean = () => true
|
||||
): () => void {
|
||||
const entry = { paste, visible };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === paste) panes.delete(runId);
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
if (lastFocused === runId) lastFocused = null;
|
||||
};
|
||||
}
|
||||
@@ -30,13 +42,21 @@ function ofLane(runId: string, laneId: number): boolean {
|
||||
return runId === base || runId.startsWith(`${base}-`);
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's mounted panes; false when none is. */
|
||||
export function pasteToLane(laneId: number, text: string): boolean {
|
||||
const id =
|
||||
lastFocused && panes.has(lastFocused) && ofLane(lastFocused, laneId)
|
||||
? lastFocused
|
||||
: [...panes.keys()].find((k) => ofLane(k, laneId));
|
||||
if (!id) return false;
|
||||
panes.get(id)!(text);
|
||||
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
|
||||
* false otherwise (not mounted yet, or hidden). */
|
||||
export function pasteToRun(runId: string, text: string): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||||
* last when it is — and returns its run id; null when none is on screen. */
|
||||
export function pasteToLane(laneId: number, text: string): string | null {
|
||||
const ok = (id: string) => ofLane(id, laneId) && !!panes.get(id)?.visible();
|
||||
const id = lastFocused && ok(lastFocused) ? lastFocused : [...panes.keys()].find(ok);
|
||||
if (!id) return null;
|
||||
panes.get(id)!.paste(text);
|
||||
return id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user