fix(terminal): macOS Vietnamese Telex keeps its capitals in Chrome too
macOS's own Vietnamese Telex types into xterm's textarea with no composition events: a letter is an `insertText`, every later key of the word an `insertReplacementText`, each keydown (keyCode 229) arriving after the change. v2.7.4 forwarded the replacements, but a capital typed with Shift still vanished — xterm drops any `input` that lands while a key is held — and its 229 fallback could resend an edit. Replayed from a real Chrome trace, "Abc xin chào" reached the pane as " x c". lib/imeInput.ts now owns those edits: capture phase on xterm's element, each one sent as DELs plus the new tail, the IME's input and 229 keydowns hidden from xterm. Keys xterm sends from keypress (Space) and real compositions (CJK) stay xterm's. Verified against xterm 5.5 in headless Chrome: Telex → "Abc xin chào"; a plain keyboard sends the same bytes as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,9 @@
|
||||
* from its terminal — announced by a hint the textarea is described by;
|
||||
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
|
||||
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
|
||||
* An input method that edits the textarea without composition events (macOS
|
||||
* Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone
|
||||
* dropped its capitals and the words it rewrites.
|
||||
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
|
||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
||||
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
|
||||
@@ -49,6 +52,7 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
@@ -519,29 +523,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const reassert = takeLead;
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
// Safari with macOS's own Vietnamese Telex types a word by REPLACING it:
|
||||
// after the first letter every key fires `insertReplacementText` carrying
|
||||
// the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards
|
||||
// only `insertText`, and its keyCode-229 fallback snapshots the textarea
|
||||
// after the change, so all but each word's first letter was lost (xterm.js
|
||||
// #5887). Send the edit ourselves: a DEL per replaced char, then the tail.
|
||||
let beforeReplace = "";
|
||||
const onBeforeReplace = (e: Event) => {
|
||||
if ((e as InputEvent).inputType === "insertReplacementText") {
|
||||
beforeReplace = term.textarea?.value ?? "";
|
||||
}
|
||||
};
|
||||
const onReplace = (e: Event) => {
|
||||
if ((e as InputEvent).inputType !== "insertReplacementText") return;
|
||||
const before = Array.from(beforeReplace);
|
||||
const after = Array.from(term.textarea?.value ?? "");
|
||||
let same = 0;
|
||||
while (same < before.length && before[same] === after[same]) same++;
|
||||
const edit = "\x7f".repeat(before.length - same) + after.slice(same).join("");
|
||||
if (edit) term.input(edit, true);
|
||||
};
|
||||
term.textarea?.addEventListener("beforeinput", onBeforeReplace);
|
||||
term.textarea?.addEventListener("input", onReplace);
|
||||
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
|
||||
// textarea with no composition events: a capital arrives while Shift is
|
||||
// held and every later key REPLACES the word — both dropped by xterm
|
||||
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
|
||||
const detachIme = attachImeInput(term);
|
||||
|
||||
const refit = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
@@ -571,8 +557,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
term.textarea?.removeEventListener("beforeinput", onBeforeReplace);
|
||||
term.textarea?.removeEventListener("input", onReplace);
|
||||
detachIme();
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
|
||||
@@ -75,6 +75,8 @@ vi.mock("@xterm/xterm", () => ({
|
||||
paste: pasteMock,
|
||||
input: inputMock,
|
||||
textarea: (textarea = document.createElement("textarea")),
|
||||
// xterm's own element, holding its textarea (lib/imeInput listens on it).
|
||||
element: document.createElement("div").appendChild(textarea).parentElement,
|
||||
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file imeInput.test.ts
|
||||
* @description Pins `attachImeInput` against a real event trace of macOS's
|
||||
* Vietnamese Simple Telex input method in Chrome (typing "Abc xin chào"): the
|
||||
* IME writes into xterm's textarea with `insertText` / `insertReplacementText`
|
||||
* and no composition events, its keydown (keyCode 229) arriving AFTER the
|
||||
* change — which stock xterm 5.5 turned into " x c". The pane must receive
|
||||
* exactly the text typed, a word the IME rewrites as backspaces plus the new
|
||||
* tail, and keys xterm already sent from `keypress` (Space) never twice.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { attachImeInput, textEdit } from "../imeInput";
|
||||
|
||||
type Step = [type: string, a: string, b: string, keyCode?: number, shift?: boolean];
|
||||
|
||||
// Recorded in the user's browser, one line per event on the textarea.
|
||||
const TELEX: Step[] = [
|
||||
["keydown", "Shift", "ShiftLeft", 16, true],
|
||||
["input", "A", "insertText"],
|
||||
["keydown", "A", "KeyA", 229, true],
|
||||
["keyup", "A", "KeyA", 65, true],
|
||||
["keyup", "Shift", "ShiftLeft", 16],
|
||||
["input", "Ab", "insertReplacementText"],
|
||||
["keydown", "b", "KeyB", 229],
|
||||
["keyup", "b", "KeyB", 66],
|
||||
["input", "Abc", "insertReplacementText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["keyup", "c", "KeyC", 67],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["keyup", " ", "Space", 32],
|
||||
["input", "x", "insertText"],
|
||||
["keydown", "x", "KeyX", 229],
|
||||
["keyup", "x", "KeyX", 88],
|
||||
["input", "xi", "insertReplacementText"],
|
||||
["keydown", "i", "KeyI", 229],
|
||||
["input", "xin", "insertReplacementText"],
|
||||
["keydown", "n", "KeyN", 229],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["input", "c", "insertText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["input", "ch", "insertReplacementText"],
|
||||
["keydown", "h", "KeyH", 229],
|
||||
["input", "cha", "insertReplacementText"],
|
||||
["keydown", "a", "KeyA", 229],
|
||||
["input", "chao", "insertReplacementText"],
|
||||
["keydown", "o", "KeyO", 229],
|
||||
["input", "chào", "insertReplacementText"],
|
||||
["keydown", "f", "KeyF", 229],
|
||||
];
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
/** A terminal stand-in: xterm's element and textarea, `input` collecting what
|
||||
* reaches the pane, and `xterm` what xterm's own textarea listeners saw. */
|
||||
function setup() {
|
||||
const element = document.createElement("div");
|
||||
const textarea = document.createElement("textarea");
|
||||
element.appendChild(textarea);
|
||||
document.body.appendChild(element);
|
||||
let sent = "";
|
||||
const xterm: string[] = [];
|
||||
for (const t of ["keydown", "keypress", "input"])
|
||||
textarea.addEventListener(t, (e) => xterm.push(`${t}:${(e as KeyboardEvent).key ?? ""}`), true);
|
||||
// xterm sends a printable key from its keypress handler (Space, here).
|
||||
textarea.addEventListener("keypress", (e) => (sent += e.key), true);
|
||||
const term = { element, textarea, input: (d: string) => (sent += d) };
|
||||
cleanup = attachImeInput(term);
|
||||
return { textarea, xterm, sent: () => sent };
|
||||
}
|
||||
|
||||
function play(ta: HTMLTextAreaElement, steps: Step[]) {
|
||||
for (const [type, a, b, kc, shift] of steps) {
|
||||
if (type === "input") {
|
||||
const before = ta.value;
|
||||
const next = b === "insertText" ? before + a : before.replace(/\S*$/, "") + a;
|
||||
ta.dispatchEvent(new InputEvent("beforeinput", { data: a, inputType: b, bubbles: true }));
|
||||
ta.value = next;
|
||||
ta.dispatchEvent(new InputEvent("input", { data: a, inputType: b, bubbles: true }));
|
||||
} else {
|
||||
const ev = new KeyboardEvent(type, { key: a, code: b, shiftKey: !!shift, bubbles: true });
|
||||
Object.defineProperty(ev, "keyCode", { get: () => kc });
|
||||
ta.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a line editor holds after `data` (DEL erases one character). */
|
||||
const typed = (data: string) => {
|
||||
const out: string[] = [];
|
||||
for (const ch of data) {
|
||||
if (ch === "\x7f") out.pop();
|
||||
else out.push(ch);
|
||||
}
|
||||
return out.join("");
|
||||
};
|
||||
|
||||
describe("textEdit", () => {
|
||||
it("is the backspaces and new tail that turn one value into the other", () => {
|
||||
expect(textEdit("Abc xin cha", "Abc xin chao")).toBe("o");
|
||||
expect(textEdit("Abc xin chao", "Abc xin chào")).toBe("\x7f\x7fào");
|
||||
expect(textEdit("x", "x")).toBe("");
|
||||
expect(textEdit("ab", "")).toBe("\x7f\x7f");
|
||||
});
|
||||
|
||||
it("counts characters, not UTF-16 units", () => {
|
||||
expect(textEdit("a😀", "a")).toBe("\x7f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachImeInput", () => {
|
||||
it("sends what Telex typed — capitals and rewritten words included", () => {
|
||||
const { textarea, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
expect(typed(sent())).toBe("Abc xin chào");
|
||||
});
|
||||
|
||||
it("leaves xterm the keys it sends itself, and hides the IME's from it", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
// Space is xterm's (its keypress sends it, its input event follows); the
|
||||
// 229 keydowns and the IME's own input events never reach it.
|
||||
expect(xterm.filter((x) => x.startsWith("keypress"))).toHaveLength(2);
|
||||
expect(xterm.filter((x) => x.startsWith("input"))).toHaveLength(2);
|
||||
expect(xterm.some((x) => x === "keydown:A" || x === "keydown:b")).toBe(false);
|
||||
expect(sent().split(" ")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not touch a composition (CJK input) — xterm handles that", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
const ev = new InputEvent("input", {
|
||||
data: "に",
|
||||
inputType: "insertCompositionText",
|
||||
bubbles: true,
|
||||
});
|
||||
textarea.value = "に";
|
||||
textarea.dispatchEvent(ev);
|
||||
expect(sent()).toBe("");
|
||||
expect(xterm).toContain("input:");
|
||||
});
|
||||
|
||||
it("stops after dispose", () => {
|
||||
const { textarea, sent } = setup();
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
play(textarea, TELEX.slice(0, 2));
|
||||
expect(sent()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file imeInput.ts
|
||||
* @description Typing through an input method that edits xterm's textarea
|
||||
* without composition events — macOS's Vietnamese Telex, in Chrome and
|
||||
* Safari, writes each letter with `insertText` and rewrites the whole word
|
||||
* with `insertReplacementText` ("cha" → "chao" → "chào"), its keydown
|
||||
* (keyCode 229) arriving only AFTER the change. xterm 5.5 drops every
|
||||
* replacement (xterm.js #5887) and every `input` that lands while a key is
|
||||
* held (Shift, for a capital), so "Abc xin chào" reached the pane as " x c".
|
||||
* `attachImeInput` turns each such change into what a line editor needs — a
|
||||
* backspace per replaced character, then the new tail — and hides those
|
||||
* events from xterm so nothing is sent twice. A key xterm sends itself (its
|
||||
* `keypress`, e.g. Space) and a real composition (CJK) stay xterm's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** DEL, the byte a terminal's Backspace sends (xterm's too). */
|
||||
const DEL = "\x7f";
|
||||
|
||||
/** The edits this module answers; anything else stays xterm's. */
|
||||
const EDITS = new Set(["insertText", "insertReplacementText", "deleteContentBackward"]);
|
||||
|
||||
/** The backspaces and new tail that turn `before` into `after` — per
|
||||
* character (code point), as the pane's line editor counts them. */
|
||||
export function textEdit(before: string, after: string): string {
|
||||
const a = Array.from(before);
|
||||
const b = Array.from(after);
|
||||
let same = 0;
|
||||
while (same < a.length && same < b.length && a[same] === b[same]) same++;
|
||||
return DEL.repeat(a.length - same) + b.slice(same).join("");
|
||||
}
|
||||
|
||||
/** The parts of an xterm `Terminal` this needs. */
|
||||
interface ImeTerminal {
|
||||
element?: HTMLElement;
|
||||
textarea?: HTMLTextAreaElement;
|
||||
input: (data: string, wasUserInput?: boolean) => void;
|
||||
}
|
||||
|
||||
/** Sends `term`'s non-composition textarea edits to the pane itself; returns
|
||||
* its dispose. Listens in the capture phase on xterm's element, so it runs
|
||||
* before xterm's own listeners on the textarea and can keep them from it. */
|
||||
export function attachImeInput(term: ImeTerminal): () => void {
|
||||
const root = term.element;
|
||||
const ta = term.textarea;
|
||||
if (!root || !ta) return () => {};
|
||||
// The value before the edit in flight (from `beforeinput`).
|
||||
let before: string | null = null;
|
||||
// xterm sent this key from its keypress: the input event that follows is its.
|
||||
let pressed = false;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target !== ta) return;
|
||||
pressed = false;
|
||||
// The IME's own key, its edit already in (or about to come as) an input
|
||||
// event: xterm's fallback would diff the textarea and send it again.
|
||||
if (e.keyCode === 229 && !e.isComposing) e.stopPropagation();
|
||||
};
|
||||
const onKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.target === ta) pressed = true;
|
||||
};
|
||||
const onBeforeInput = (e: Event) => {
|
||||
if (e.target === ta) before = ta.value;
|
||||
};
|
||||
const onInput = (e: Event) => {
|
||||
const ev = e as InputEvent;
|
||||
if (ev.target !== ta) return;
|
||||
const was = before;
|
||||
before = null;
|
||||
if (pressed) {
|
||||
pressed = false;
|
||||
return;
|
||||
}
|
||||
if (ev.isComposing || !EDITS.has(ev.inputType) || was === null) return;
|
||||
e.stopPropagation();
|
||||
const data = textEdit(was, ta.value);
|
||||
if (data) term.input(data, true);
|
||||
};
|
||||
|
||||
root.addEventListener("keydown", onKeyDown, true);
|
||||
root.addEventListener("keypress", onKeyPress, true);
|
||||
root.addEventListener("beforeinput", onBeforeInput, true);
|
||||
root.addEventListener("input", onInput, true);
|
||||
return () => {
|
||||
root.removeEventListener("keydown", onKeyDown, true);
|
||||
root.removeEventListener("keypress", onKeyPress, true);
|
||||
root.removeEventListener("beforeinput", onBeforeInput, true);
|
||||
root.removeEventListener("input", onInput, true);
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -406,7 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub workspace mcp sync` and `agenthub workspace sync-base`, and a readme refresh is a skill typed into a session.
|
||||
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
|
||||
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
|
||||
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. An input method that edits the terminal's text box without composition events — macOS's Vietnamese Telex, in Chrome and Safari, which writes each letter as `insertText` and rewrites the word being typed as `insertReplacementText` — is sent to the pane by `client/src/lib/imeInput.ts` as backspaces plus the new tail of the word; xterm 5.5 on its own dropped those rewrites and any capital typed with Shift. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
|
||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
|
||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
|
||||
|
||||
Reference in New Issue
Block a user