Compare commits
1 Commits
v2.7.4
...
69a2c37cd2
| Author | SHA1 | Date | |
|---|---|---|---|
| 69a2c37cd2 |
@@ -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