feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass).
This commit is contained in:
+1
-1
@@ -601,7 +601,7 @@ graph LR
|
||||
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
|
||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
||||
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
|
||||
| `/*` | NotFound | None (static 404 page) |
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* the route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
* auto-setup calls against a cwd that must actually exist on disk first.
|
||||
* An optional "Create/update readme file" checkbox starts the lane's terminal
|
||||
* run right after creation with `/sg-all:sg-md-writing` as its first message.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -96,7 +94,6 @@ export function AddLaneModal({
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [base, setBase] = useState("");
|
||||
const [pipeline, setPipeline] = useState("default");
|
||||
const [writeDocs, setWriteDocs] = useState(false);
|
||||
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
|
||||
const [router, setRouter] = useState("native");
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
@@ -122,7 +119,6 @@ export function AddLaneModal({
|
||||
setBranches(null);
|
||||
setBase("");
|
||||
setPipeline("default");
|
||||
setWriteDocs(false);
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
@@ -208,14 +204,6 @@ export function AddLaneModal({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, sourceRepo, t]);
|
||||
|
||||
// The checkbox below runs the docs skill by opening the lane's own terminal
|
||||
// run with the slash command as its first message — a skill only exists
|
||||
// inside a CLI session, so there is nothing server-side to call instead.
|
||||
// Claude-only: codex has no slash commands (same reason LaneConsolePane
|
||||
// prefills prose for a codex lane).
|
||||
const startDocsRun = (laneId: number, cwd: string) =>
|
||||
api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" });
|
||||
|
||||
// Native is the built-in profile, so this is one call either way — there is
|
||||
// no "no router" branch to keep in step with the real one.
|
||||
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
|
||||
@@ -242,19 +230,14 @@ export function AddLaneModal({
|
||||
// Worktree mode below already works this way.
|
||||
onAdded(result.lane);
|
||||
|
||||
// Router first: a session that booted on the vendor's API would keep
|
||||
// talking to it for its whole life.
|
||||
const [routerOutcome, docsOutcome] = await Promise.allSettled([
|
||||
applyRouter(result.lane.id),
|
||||
writeDocs && tool === "claude"
|
||||
? startDocsRun(result.lane.id, result.lane.cwd)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected");
|
||||
if (failed && failed.status === "rejected") {
|
||||
// A session that booted on the vendor's API would keep talking to it
|
||||
// for its whole life, so the router is applied before anything runs.
|
||||
try {
|
||||
await applyRouter(result.lane.id);
|
||||
} catch (routerErr) {
|
||||
// The lane exists and is already listed — name what did not finish
|
||||
// instead of closing on a half-set-up lane.
|
||||
setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason));
|
||||
setError(routerErr instanceof Error ? routerErr.message : String(routerErr));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
@@ -312,10 +295,6 @@ export function AddLaneModal({
|
||||
const anyFailed = Object.values(outcome).includes("failed");
|
||||
setSetupResult(outcome);
|
||||
|
||||
if (writeDocs && tool === "claude") {
|
||||
await startDocsRun(result.lane.id, provisionedLane.cwd);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
profile:
|
||||
@@ -555,39 +534,6 @@ export function AddLaneModal({
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
{tool === "claude" && (
|
||||
<label
|
||||
// No tick box: the card itself lights up when selected, the same
|
||||
// treatment LaneStripCard uses, which makes a second indicator of
|
||||
// the same state redundant. The input stays and only goes visually
|
||||
// hidden — it is still what a keyboard toggles and what a screen
|
||||
// reader announces — so the card carries the focus ring for it.
|
||||
className={`block cursor-pointer rounded-lg border p-3 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
writeDocs
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
// The wrapping label also holds the hint, so name the input
|
||||
// itself — otherwise its accessible name is label + hint.
|
||||
aria-label={t("addLaneWriteDocsLabel")}
|
||||
checked={writeDocs}
|
||||
onChange={(e) => setWriteDocs(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
|
||||
>
|
||||
{t("addLaneWriteDocsLabel")}
|
||||
</span>
|
||||
<span className="mt-1 block text-[10px] text-fg-muted">
|
||||
{t("addLaneWriteDocsHint")}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* second telling of it. The repo
|
||||
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
||||
* only other place one is chosen, and a lane outlives its creation. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file — agents
|
||||
* install / MCP sync / dev-sync check / create-update readme) and the
|
||||
* remove-lane button live in Workspace's page header / pipeline-picker row
|
||||
* instead of here, so they stay reachable and un-clipped regardless of this
|
||||
* card's own scroll area.
|
||||
@@ -307,7 +308,8 @@ export function since(sec: number | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check) for one lane,
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check, and the
|
||||
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
|
||||
* collapsed into a single dropdown. Lives outside LaneCard so it can be
|
||||
* placed in the Workspace page header, next to "Add lane" — it polls its
|
||||
* own runtime state independently of LaneCard's, same pattern as the
|
||||
@@ -316,14 +318,14 @@ export function since(sec: number | null): string {
|
||||
export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
||||
null
|
||||
);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<
|
||||
"agents" | "mcp" | "sync-check" | "docs" | null
|
||||
>(null);
|
||||
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||
const runtime = useLaneRuntime(lane.id, 0);
|
||||
|
||||
const runLaneAction = async (
|
||||
which: "agents" | "mcp" | "sync-check",
|
||||
which: "agents" | "mcp" | "sync-check" | "docs",
|
||||
fn: () => Promise<string>
|
||||
) => {
|
||||
setLaneActionBusy(which);
|
||||
@@ -343,6 +345,19 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
||||
});
|
||||
|
||||
// A skill only exists inside a CLI session, so there is nothing server-side
|
||||
// to call: this opens the lane's own terminal run with the slash command as
|
||||
// its first message. Claude-only — codex has no slash commands.
|
||||
const handleWriteDocs = () =>
|
||||
runLaneAction("docs", async () => {
|
||||
await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
});
|
||||
return t("actions.writeDocsResult");
|
||||
});
|
||||
|
||||
const handleMcpSync = () =>
|
||||
runLaneAction("mcp", async () => {
|
||||
const result = await api.lanes.mcpSync(lane.id);
|
||||
@@ -398,6 +413,22 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
>
|
||||
{t("actions.agentsInstall")}
|
||||
</button>
|
||||
{lane.tool !== "codex" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-write-docs"
|
||||
title={t("actions.writeDocsHint")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setToolsMenuOpen(false);
|
||||
void handleWriteDocs();
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("actions.writeDocs")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -134,15 +134,13 @@ beforeEach(() => {
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: null,
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
profile: "native",
|
||||
});
|
||||
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: null,
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
profile: "native",
|
||||
});
|
||||
// Only the built-in profile by default, so the router radio stays on Native
|
||||
// and no test has to opt out of it.
|
||||
vi.mocked(api.aiRouters.list)
|
||||
@@ -452,37 +450,12 @@ describe("AddLaneModal — folder browse", () => {
|
||||
});
|
||||
|
||||
describe("AddLaneModal — create/update readme", () => {
|
||||
it("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
|
||||
vi.mocked(api.run.start)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
it("no longer offers it here — it moved to the lane's Tools menu", async () => {
|
||||
// Creating a lane and writing its docs are separate decisions, and the
|
||||
// second one is worth re-running later; see LaneToolsMenu.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByLabelText("Create/update readme file"));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.run.start).toHaveBeenCalledWith({
|
||||
laneId: 9,
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("is hidden for a codex lane, which has no slash commands", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Codex" }));
|
||||
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -462,6 +462,29 @@ describe("LaneToolsMenu", () => {
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
||||
});
|
||||
|
||||
it("clicking Create/update readme opens the lane's terminal on the docs skill", async () => {
|
||||
// Moved here from the Add-lane modal: a skill only runs inside a CLI
|
||||
// session, and writing the docs is worth re-running long after creation.
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneToolsMenu lane={makeLane({ cwd: "/work/demo" })} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-write-docs"));
|
||||
await waitFor(() =>
|
||||
expect(api.run.start).toHaveBeenCalledWith({
|
||||
laneId: 1,
|
||||
cwd: "/work/demo",
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("hides Create/update readme on a codex lane, which has no slash commands", async () => {
|
||||
render(<LaneToolsMenu lane={makeLane({ tool: "codex" })} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
|
||||
|
||||
@@ -75,7 +75,7 @@ export function LaneConsolePane({
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("auto");
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
|
||||
const [resumeSession, setResumeSession] = useState<Session | null>(null);
|
||||
@@ -491,6 +491,13 @@ export function LaneConsolePane({
|
||||
<TerminalView
|
||||
runId={handle!.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// go straight back to the setup form rather than leaving a dead
|
||||
// terminal the user has to click "New run" out of.
|
||||
onExit={() => {
|
||||
newRun();
|
||||
refreshList();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={newRun}
|
||||
|
||||
@@ -203,6 +203,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "auto", label: t("fields.permissionAuto") },
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
|
||||
@@ -20,10 +20,19 @@ import "@xterm/xterm/css/xterm.css";
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
/** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit).
|
||||
* There is nothing left to attach to, so the owner swaps this terminal back
|
||||
* out for its run setup form instead of leaving a dead pane on screen. */
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
// every render, and as an effect dep it would tear the terminal down and
|
||||
// re-attach on each one.
|
||||
const onExitRef = useRef(onExit);
|
||||
onExitRef.current = onExit;
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -35,8 +44,15 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too.
|
||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||
// it on a PTY, where termios already does that translation for the streams
|
||||
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
|
||||
// drawing at the column it was on; with this on, that draw landed at
|
||||
// column 0 instead, stamping the first characters of the line into the
|
||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
@@ -63,6 +79,10 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
term.focus();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
@@ -116,7 +136,16 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
let disposed = false;
|
||||
|
||||
const connect = () => {
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||
// The attach itself carries this terminal's size. tmux sizes the window
|
||||
// to the client that attached most recently, so spawning the attach at a
|
||||
// default 80×24 and correcting it from the first `resize` message a beat
|
||||
// later makes the pane's program (claude) redraw its whole UI at 80
|
||||
// columns first — and everything painted at that width stays in the
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// lane switch, and each 1.5 s reconnect.
|
||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
ws = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
@@ -143,6 +172,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
onExitRef.current?.();
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
|
||||
@@ -11,8 +11,12 @@ import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
// out of claude), which the real TerminalView reports through `onExit`.
|
||||
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId}>
|
||||
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -130,6 +134,35 @@ describe("LaneConsolePane", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to the setup form when the pane exits, without a New run click", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "run-1" },
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-1",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(<LaneConsolePane {...baseProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await screen.findByTestId("terminal-view");
|
||||
|
||||
fireEvent.click(screen.getByTestId("fake-pane-exit"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||
const run = (id: string, laneId: number) => ({
|
||||
|
||||
@@ -121,8 +121,10 @@ describe("RunSetup — selections report through callbacks", () => {
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 5");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("claude-sonnet-5");
|
||||
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
|
||||
// what keeps the list from going stale on every model release.
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
|
||||
@@ -16,6 +16,7 @@ const openMock = vi.fn();
|
||||
const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -54,6 +55,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
@@ -100,13 +102,27 @@ describe("TerminalView", () => {
|
||||
keyHandlerMock.mockClear();
|
||||
loadAddonMock.mockClear();
|
||||
openMock.mockClear();
|
||||
focusMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("opens a WS connection to the run's ws-pty path", () => {
|
||||
it("opens a WS connection to the run's ws-pty path, carrying its own size", () => {
|
||||
// The size rides on the attach URL, not on a `resize` message sent after
|
||||
// it: tmux resizes the window to the newest client, so attaching at the
|
||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||
// leaves that width in the scrollback (issue #33).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||
termState.cols = 0;
|
||||
termState.rows = 0;
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||
});
|
||||
|
||||
@@ -184,6 +200,24 @@ describe("TerminalView", () => {
|
||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the keyboard as soon as it mounts", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// opening a lane went nowhere until the user clicked the pane.
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(focusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells its owner when the pane exited, so the setup form can come back", () => {
|
||||
// A double Ctrl+C out of `claude` ends the tmux session; the dead pane used
|
||||
// to sit there until the user found the "New run" button.
|
||||
const onExit = vi.fn();
|
||||
render(
|
||||
<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" onExit={onExit} />
|
||||
);
|
||||
MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' });
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
|
||||
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
|
||||
"actions.tools": "Tools",
|
||||
"actions.writeDocs": "Create/update readme file",
|
||||
"actions.writeDocsHint": "Opens this lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
|
||||
"actions.writeDocsResult": "Started — see this lane's terminal.",
|
||||
"add": "Add lane",
|
||||
"addLane": "Create a lane from a working directory",
|
||||
"addLaneBaseLabel": "Branch to fork from",
|
||||
@@ -49,8 +52,6 @@
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||
"addLaneWriteDocsHint": "On creation, opens the lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
|
||||
"addLaneWriteDocsLabel": "Create/update readme file",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"browse.button": "Browse",
|
||||
"browse.empty": "This folder is empty.",
|
||||
@@ -93,7 +94,8 @@
|
||||
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
|
||||
"destructive.reset.title": "Reset managed worktree?",
|
||||
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
||||
"emptyState": "No lanes yet. Create one from a working directory:",
|
||||
"empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal:",
|
||||
"empty.title": "No lanes yet",
|
||||
"features.archived": "archived",
|
||||
"features.live": "Live",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"modelCustom": "Custom…",
|
||||
"modelCustomPlaceholder": "model id (e.g. claude-opus-5)",
|
||||
"modelInheritLabel": "Inherit from settings",
|
||||
"permissionAcceptEdits": "acceptEdits (recommended)",
|
||||
"permissionAcceptEdits": "acceptEdits (file edits without asking)",
|
||||
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
||||
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||
"permissionMode": "Permission mode",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
|
||||
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
|
||||
"actions.tools": "Công cụ",
|
||||
"actions.writeDocs": "Tạo/cập nhật file readme",
|
||||
"actions.writeDocsHint": "Mở terminal của lane này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
|
||||
"actions.writeDocsResult": "Đã chạy — xem terminal của lane này.",
|
||||
"add": "Thêm lane",
|
||||
"addLane": "Tạo lane từ một thư mục làm việc",
|
||||
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
|
||||
@@ -49,8 +52,6 @@
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
|
||||
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"browse.button": "Duyệt",
|
||||
"browse.empty": "Thư mục này trống.",
|
||||
@@ -93,7 +94,8 @@
|
||||
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
|
||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
||||
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
||||
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
|
||||
"empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Thêm ở đây, hoặc từ terminal:",
|
||||
"empty.title": "Chưa có lane nào",
|
||||
"features.archived": "đã lưu trữ",
|
||||
"features.live": "Phiên bản trực tiếp",
|
||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"modelCustom": "Tuỳ chỉnh…",
|
||||
"modelCustomPlaceholder": "model id (vd: claude-opus-5)",
|
||||
"modelInheritLabel": "Kế thừa từ settings",
|
||||
"permissionAcceptEdits": "acceptEdits (khuyên dùng)",
|
||||
"permissionAcceptEdits": "acceptEdits (sửa file không hỏi)",
|
||||
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
||||
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||
"permissionMode": "Permission mode",
|
||||
|
||||
+12
-5
@@ -3390,7 +3390,7 @@ export interface CodexMarketplaceActionArgs {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
|
||||
export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
|
||||
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
|
||||
@@ -3515,10 +3515,17 @@ export const RUN_EFFORT_CHOICES: EffortChoice[] = [
|
||||
// configured default and the rest map to concrete `--model` values.
|
||||
export const RUN_MODEL_CHOICES: ModelChoice[] = [
|
||||
{ id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" },
|
||||
{ id: "claude-opus-5", label: "Opus 5", hint: "Highest capability" },
|
||||
{ id: "claude-sonnet-5", label: "Sonnet 5", hint: "Balanced capability and speed" },
|
||||
{ id: "claude-fable-5", label: "Fable 5", hint: "Fast output, same capability tier as Opus" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", hint: "Fastest, lightest" },
|
||||
// Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/
|
||||
// `sonnet`/`haiku` as "an alias for the latest model", and the CLI resolves
|
||||
// each one itself (2.1.278 maps fable -> claude-fable-5-1). A pinned id here
|
||||
// is a list that silently goes stale every release and has to be edited by
|
||||
// hand; an alias follows whatever `claude` was last updated to. Cost math is
|
||||
// unaffected — pricing keys off the model the transcript reports, which is
|
||||
// the resolved full id.
|
||||
{ id: "fable", label: "Fable (latest)", hint: "Fast output, same capability tier as Opus" },
|
||||
{ id: "opus", label: "Opus (latest)", hint: "Highest capability" },
|
||||
{ id: "sonnet", label: "Sonnet (latest)", hint: "Balanced capability and speed" },
|
||||
{ id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" },
|
||||
];
|
||||
|
||||
// Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react";
|
||||
import { Plus, Square, Columns2, Grid2x2, Pencil, Layers } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
@@ -574,37 +574,68 @@ export function Workspace() {
|
||||
scrolling row (holds any number of lanes), the detail/console area
|
||||
below it gets the full width and never competes for it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
{lanes.length === 0 ? (
|
||||
/* With no lanes the strip was a dashed tile beside a stray sentence,
|
||||
which reads as a half-rendered row rather than a state. One banner
|
||||
instead — and a compact one on purpose: the run setup form below
|
||||
is what the user actually needs the height for. */
|
||||
<div
|
||||
data-testid="lane-empty"
|
||||
className="card flex shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
{!lanes.length && (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{tLanes("emptyState")} <code>agenthub lanes add --cwd $(pwd)</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
|
||||
<Layers className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-medium text-fg-primary">{tLanes("empty.title")}</h2>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{tLanes("empty.body")}{" "}
|
||||
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
|
||||
agenthub lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="btn-primary shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="lane-strip"
|
||||
className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
>
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The selected lane's pipeline — the thing you actually come to this
|
||||
page to read, beside the list rather than stacked under it.
|
||||
|
||||
@@ -5100,13 +5100,58 @@ exports[`screen snapshots > Run 1`] = `
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
data-testid="lane-strip"
|
||||
class="card flex shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4"
|
||||
data-testid="lane-empty"
|
||||
>
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-layers h-5 w-5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
|
||||
/>
|
||||
<path
|
||||
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
|
||||
/>
|
||||
<path
|
||||
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<h2
|
||||
class="text-sm font-medium text-fg-primary"
|
||||
>
|
||||
No lanes yet
|
||||
</h2>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal:
|
||||
|
||||
<code
|
||||
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
agenthub lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
class="btn-primary shrink-0"
|
||||
data-testid="lane-tile-add"
|
||||
title="Create a lane from a working directory"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
@@ -5130,15 +5175,6 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</svg>
|
||||
Add lane
|
||||
</button>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No lanes yet. Create one from a working directory:
|
||||
|
||||
<code>
|
||||
agenthub lanes add --cwd $(pwd)
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<section
|
||||
class="flex min-h-0 min-w-0 flex-1 flex-col "
|
||||
@@ -5444,7 +5480,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
acceptEdits (recommended)
|
||||
auto (recommended - asks only for risky actions)
|
||||
</span>
|
||||
<svg
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
|
||||
+2
-2
@@ -1767,7 +1767,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||
|
||||
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
|
||||
|
||||
`permissionMode` (`acceptEdits` default, or `default` / `plan` / `bypassPermissions`) is the mode the pane starts in. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it.
|
||||
`permissionMode` (`auto` default, or `acceptEdits` / `default` / `plan` / `bypassPermissions`) is the mode the pane starts in — `auto` is the CLI's own classifier mode: it asks only for what it reads as risky. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it.
|
||||
|
||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||
|
||||
@@ -1990,7 +1990,7 @@ Sent when a notification is created.
|
||||
|
||||
#### /ws-pty/:runId — PTY frames
|
||||
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
|
||||
#### cc_config_changed
|
||||
|
||||
|
||||
+7
-5
@@ -45,8 +45,6 @@ The modal has a segmented toggle mirroring the CLI's two modes:
|
||||
|
||||
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
|
||||
|
||||
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
|
||||
|
||||
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
|
||||
|
||||
## Lane tool: Claude or Codex
|
||||
@@ -372,11 +370,13 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
|
||||
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
|
||||
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and a primary **Add lane** button. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
|
||||
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which.
|
||||
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Detail panel** — the selected lane'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.
|
||||
- **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.
|
||||
- **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, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
|
||||
- **Clipboard in a pane** — the lane'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.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||
|
||||
@@ -1301,7 +1301,9 @@ curl -X POST http://localhost:4820/api/run \
|
||||
|
||||
The `initialPrompt` field is optional — if omitted, the tmux session is created/attached with no initial input, and you type into the terminal directly. Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The same tmux session persists across attach/detach cycles, so you can switch between the browser Workspace and `agenthub lanes shell` seamlessly.
|
||||
|
||||
**Permission mode in a lane run.** `permissionMode` (one of `acceptEdits` — the default — `default`, `plan`, `bypassPermissions`) sets the mode the pane *starts* in. The pane's `claude` also gets `--allow-dangerously-skip-permissions`, which puts **bypass permissions** into the pane's own `shift+tab` cycle **without enabling it** — matching what you get in a real terminal, where the cycle would otherwise be limited to plan / auto / manual / accept-edits and bypass would be unreachable from inside a dashboard-started run. Bypass is still only ever entered by an explicit `shift+tab` in the pane or by asking for `permissionMode: "bypassPermissions"` up front; nothing skips permission checks by default.
|
||||
**The model picker offers aliases, not pinned ids.** The Run setup's Model select sends `fable` / `opus` / `sonnet` / `haiku` (plus `Custom…` for a literal id, and the empty value that inherits `settings.json`). `claude --model` documents those as *"an alias for the latest model"* and resolves each itself — 2.1.278 maps `fable` to `claude-fable-5-1` — so a new model release reaches the dashboard the moment the CLI is updated, with no list to edit by hand. Cost maths is unaffected: pricing keys off the model id the transcript reports, which is always the resolved full name. Codex has no such aliases, so `RUN_MODEL_CHOICES_CODEX` stays a pinned list.
|
||||
|
||||
**Permission mode in a lane run.** `permissionMode` (one of `auto` — the default — `acceptEdits`, `default`, `plan`, `bypassPermissions`) sets the mode the pane *starts* in. `auto` hands the decision to the CLI's own classifier, which asks only for what it reads as risky; it is what a terminal `claude` starts in too. The pane's `claude` also gets `--allow-dangerously-skip-permissions`, which puts **bypass permissions** into the pane's own `shift+tab` cycle **without enabling it** — matching what you get in a real terminal, where the cycle would otherwise be limited to plan / auto / manual / accept-edits and bypass would be unreachable from inside a dashboard-started run. Bypass is still only ever entered by an explicit `shift+tab` in the pane or by asking for `permissionMode: "bypassPermissions"` up front; nothing skips permission checks by default.
|
||||
|
||||
**A killed run releases the lane.** When the tmux session is terminated (from the terminal, browser stop button, or external `kill`), the lane's `run_id` is cleared, its status returns to `idle`, and the change is broadcast as `lane_update`. Runs are recorded with the lane's id and are listable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
|
||||
+9
-6
@@ -3428,12 +3428,13 @@ components:
|
||||
permissionMode:
|
||||
type: string
|
||||
enum:
|
||||
- auto
|
||||
- acceptEdits
|
||||
- default
|
||||
- plan
|
||||
- bypassPermissions
|
||||
description: Permission mode passed via `--permission-mode`. Defaults to `acceptEdits` when omitted or invalid.
|
||||
example: acceptEdits
|
||||
description: Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid.
|
||||
example: auto
|
||||
effort:
|
||||
type: string
|
||||
enum:
|
||||
@@ -3611,12 +3612,13 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- auto
|
||||
- acceptEdits
|
||||
- default
|
||||
- plan
|
||||
- bypassPermissions
|
||||
description: Permission mode recorded at spawn time.
|
||||
example: acceptEdits
|
||||
example: auto
|
||||
effort:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -3792,13 +3794,14 @@ components:
|
||||
permissionMode:
|
||||
type: string
|
||||
enum:
|
||||
- auto
|
||||
- acceptEdits
|
||||
- default
|
||||
- plan
|
||||
- bypassPermissions
|
||||
default: acceptEdits
|
||||
description: Permission mode passed via `--permission-mode`. Unknown values silently fall back to `acceptEdits`.
|
||||
example: acceptEdits
|
||||
default: auto
|
||||
description: Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`.
|
||||
example: auto
|
||||
RunSpawnConcurrencyResponse:
|
||||
type: object
|
||||
description: HTTP 429 body when the concurrency cap is reached (ECONCURRENCY). In addition to the standard `error` envelope, this response carries a top-level `running` array listing the runs currently occupying the cap.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file desktop-env.test.js
|
||||
* @description Unit tests for the graphical-session variables forwarded into a
|
||||
* lane's tmux pane. Without them a `claude` started by the dashboard has no
|
||||
* DISPLAY/WAYLAND_DISPLAY at all and answers "no image found in clipboard" on
|
||||
* a paste, while the same CLI works in a normal terminal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
|
||||
|
||||
const saved = {};
|
||||
|
||||
describe("desktopEnv", () => {
|
||||
beforeEach(() => {
|
||||
for (const name of FORWARDED) {
|
||||
saved[name] = process.env[name];
|
||||
delete process.env[name];
|
||||
}
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const name of FORWARDED) {
|
||||
if (saved[name] === undefined) delete process.env[name];
|
||||
else process.env[name] = saved[name];
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the systemd user manager's environment", () => {
|
||||
__setExecImpl(
|
||||
() => "XDG_RUNTIME_DIR=/run/user/1001\nDISPLAY=:0\nXAUTHORITY=/run/user/1001/gdm/Xauthority\n"
|
||||
);
|
||||
assert.deepEqual(desktopEnv(), {
|
||||
DISPLAY: ":0",
|
||||
XAUTHORITY: "/run/user/1001/gdm/Xauthority",
|
||||
XDG_RUNTIME_DIR: "/run/user/1001",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers this process's own value — a checkout run from a terminal has the real one", () => {
|
||||
process.env.DISPLAY = ":1";
|
||||
__setExecImpl(() => "DISPLAY=:0\n");
|
||||
assert.equal(desktopEnv().DISPLAY, ":1");
|
||||
});
|
||||
|
||||
it("forwards nothing when there is no systemd user manager (macOS, a container)", () => {
|
||||
__setExecImpl(() => {
|
||||
throw new Error("systemctl: not found");
|
||||
});
|
||||
assert.deepEqual(desktopEnv(), {});
|
||||
});
|
||||
|
||||
it("forwards only the clipboard-related names, never the whole session", () => {
|
||||
__setExecImpl(() => "DISPLAY=:0\nSSH_AUTH_SOCK=/tmp/agent\nPATH=/usr/bin\n");
|
||||
assert.deepEqual(Object.keys(desktopEnv()), ["DISPLAY"]);
|
||||
});
|
||||
|
||||
it("drops a value carrying a newline — it becomes a tmux -e argv", () => {
|
||||
__setExecImpl(() => "");
|
||||
process.env.WAYLAND_DISPLAY = "wayland-0\ninjected";
|
||||
assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined);
|
||||
});
|
||||
});
|
||||
@@ -193,7 +193,10 @@ describe("pty-run", () => {
|
||||
// `--dangerously-skip-permissions` (which would enable it) is never passed.
|
||||
assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions"));
|
||||
const modeAt = newSessionArgv.indexOf("--permission-mode");
|
||||
assert.equal(newSessionArgv[modeAt + 1], "acceptEdits");
|
||||
// `auto` is the default when the caller named no mode: the CLI's own
|
||||
// classifier asks for what it reads as risky and stays out of the way
|
||||
// otherwise.
|
||||
assert.equal(newSessionArgv[modeAt + 1], "auto");
|
||||
});
|
||||
|
||||
it("spawnRun appends a positional initial prompt after argv flags", () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file websocket-pty-size.test.js
|
||||
* @description Regression test for issue #33: the `/ws-pty/:runId` attach
|
||||
* spawns `tmux attach-session` at the size the CLIENT reports in the attach
|
||||
* URL, not a hardcoded 80×24. tmux sizes the window to the newest client, so
|
||||
* attaching at 80 columns and fixing it from the first `resize` message made
|
||||
* `claude` redraw its whole UI at 80 columns — wreckage that stays in the
|
||||
* scrollback and shows up as torn left-margin fragments when scrolling back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { sizeFromQuery } = require("../websocket");
|
||||
|
||||
const query = (search) => new URL(`http://x/ws-pty/agenthub-lane-1${search}`).searchParams;
|
||||
|
||||
describe("sizeFromQuery — the attach carries the client's real size", () => {
|
||||
it("reads the terminal's own cols/rows", () => {
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 });
|
||||
});
|
||||
|
||||
it("falls back to 80x24 when the client named no size", () => {
|
||||
assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 });
|
||||
});
|
||||
|
||||
it("refuses anything out of bounds — this number reaches an ioctl", () => {
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 });
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 });
|
||||
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file desktop-env.js
|
||||
* @description The graphical session's own environment variables, for panes
|
||||
* the dashboard spawns. The server usually runs as a systemd *user* service,
|
||||
* which has no `DISPLAY`/`WAYLAND_DISPLAY` of its own — so neither does any
|
||||
* tmux pane it starts, and a `claude` running in one cannot reach the X or
|
||||
* Wayland clipboard: pasting an image answers "no image found in clipboard"
|
||||
* while the same CLI in a normal terminal pastes fine. `systemctl --user
|
||||
* show-environment` is the canonical local source for those values (the
|
||||
* desktop session imports them there at login), with this process's own
|
||||
* environment preferred when it already has them — a checkout started from a
|
||||
* terminal inherits the real ones.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
/** The only names forwarded. A pane gets a clipboard, not the whole session. */
|
||||
const FORWARDED = ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"];
|
||||
|
||||
let execImpl = (args) =>
|
||||
execFileSync("systemctl", args, {
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
|
||||
/** Test seam — same pattern as tmux.js. */
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/** `KEY=value` lines from `systemctl --user show-environment`, as a map. */
|
||||
function systemdUserEnv() {
|
||||
let out;
|
||||
try {
|
||||
out = execImpl(["--user", "show-environment"]);
|
||||
} catch {
|
||||
// No systemd user manager (macOS, a container, a plain login shell) — the
|
||||
// process environment below is the only source then.
|
||||
return {};
|
||||
}
|
||||
const env = {};
|
||||
for (const line of String(out).split("\n")) {
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0) env[line.slice(0, eq)] = line.slice(eq + 1);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarded variables that actually have a usable value right now.
|
||||
* A value carrying a newline is dropped rather than passed on: these end up in
|
||||
* a `tmux -e NAME=value` argv, and a line break there is not a value tmux can
|
||||
* represent.
|
||||
*/
|
||||
function desktopEnv() {
|
||||
const fromSystemd = systemdUserEnv();
|
||||
const out = {};
|
||||
for (const name of FORWARDED) {
|
||||
const value = process.env[name] || fromSystemd[name];
|
||||
if (typeof value === "string" && value && !/[\r\n\0]/.test(value)) out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { desktopEnv, FORWARDED, __setExecImpl };
|
||||
+19
-3
@@ -26,6 +26,7 @@
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
|
||||
let dashboardRuns = null;
|
||||
try {
|
||||
@@ -36,7 +37,16 @@ try {
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
|
||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||||
// it asks only for what its classifier reads as risky. `default` is kept for
|
||||
// callers that stored it, though this CLI now spells that one `manual`.
|
||||
const ALLOWED_PERMISSION_MODES = new Set([
|
||||
"auto",
|
||||
"acceptEdits",
|
||||
"default",
|
||||
"plan",
|
||||
"bypassPermissions",
|
||||
]);
|
||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
const VALID_TOOLS = new Set(["claude", "codex"]);
|
||||
@@ -70,7 +80,7 @@ function makeErr(code, message) {
|
||||
*/
|
||||
function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
|
||||
const argv = ["claude"];
|
||||
argv.push("--permission-mode", permissionMode || "acceptEdits");
|
||||
argv.push("--permission-mode", permissionMode || "auto");
|
||||
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
|
||||
// it on: `claude --help` — "Enable bypassing all permission checks as an
|
||||
// option, without it being enabled by default." Without it the cycle is only
|
||||
@@ -235,7 +245,7 @@ function spawnRun(args) {
|
||||
cwd,
|
||||
tool: tool === "codex" ? "codex" : "claude",
|
||||
model: model || null,
|
||||
permissionMode: permissionMode || "acceptEdits",
|
||||
permissionMode: permissionMode || "auto",
|
||||
effort: effort || null,
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
prompt: initialPrompt || "",
|
||||
@@ -254,8 +264,14 @@ function spawnRun(args) {
|
||||
// A codex lane on an AI Router also needs the key its layer's `env_key`
|
||||
// names. A claude lane needs nothing here — its settings file already carries
|
||||
// the router — and a lane on the vendor's own API needs nothing either.
|
||||
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
|
||||
// server normally runs as a systemd user service with neither, so a pane it
|
||||
// starts has no clipboard at all — pasting an image into `claude` there
|
||||
// answers "no image found in clipboard" while the same CLI pastes fine in a
|
||||
// terminal. Never overrides what this spawn already needs.
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
...desktopEnv(),
|
||||
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
|
||||
};
|
||||
|
||||
|
||||
@@ -102,10 +102,10 @@ const schemas = {
|
||||
},
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
enum: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
description:
|
||||
"Permission mode passed via `--permission-mode`. Defaults to `acceptEdits` when omitted or invalid.",
|
||||
example: "acceptEdits",
|
||||
"Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid.",
|
||||
example: "auto",
|
||||
},
|
||||
effort: {
|
||||
type: "string",
|
||||
@@ -316,9 +316,9 @@ const schemas = {
|
||||
permission_mode: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
enum: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
description: "Permission mode recorded at spawn time.",
|
||||
example: "acceptEdits",
|
||||
example: "auto",
|
||||
},
|
||||
effort: {
|
||||
type: "string",
|
||||
@@ -516,11 +516,11 @@ const schemas = {
|
||||
},
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
enum: ["acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
default: "acceptEdits",
|
||||
enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"],
|
||||
default: "auto",
|
||||
description:
|
||||
"Permission mode passed via `--permission-mode`. Unknown values silently fall back to `acceptEdits`.",
|
||||
example: "acceptEdits",
|
||||
"Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`.",
|
||||
example: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,7 +60,13 @@ function sanitiseCwd(input) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
const ALLOWED_PERMISSION_MODES = new Set([
|
||||
"auto",
|
||||
"acceptEdits",
|
||||
"default",
|
||||
"plan",
|
||||
"bypassPermissions",
|
||||
]);
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ items: runs.listRuns() });
|
||||
@@ -248,7 +254,7 @@ router.post("/", (req, res) => {
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
? body.permissionMode
|
||||
: "acceptEdits",
|
||||
: "auto",
|
||||
effort: typeof body.effort === "string" && body.effort ? body.effort : null,
|
||||
resumeSessionId:
|
||||
typeof body.resumeSessionId === "string" && body.resumeSessionId
|
||||
|
||||
+28
-4
@@ -82,6 +82,23 @@ function initWebSocket(server) {
|
||||
let ptyWss = null;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+)$/;
|
||||
|
||||
/**
|
||||
* The attaching terminal's own size, off the attach URL's query. tmux resizes
|
||||
* the window to whichever client attached most recently, so a hardcoded 80×24
|
||||
* here is not a harmless default: the pane's program redraws its whole UI at
|
||||
* 80 columns and only gets the real width back when the client's first
|
||||
* `resize` message lands, leaving 80-column wreckage in the scrollback that
|
||||
* shows up as torn left-margin fragments when the user scrolls back
|
||||
* (issue #33). Bounded, because this number reaches a `TIOCSWINSZ` ioctl.
|
||||
*/
|
||||
function sizeFromQuery(params) {
|
||||
const read = (name, fallback) => {
|
||||
const parsed = Number.parseInt(params.get(name) ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed >= 2 && parsed <= 1000 ? parsed : fallback;
|
||||
};
|
||||
return { cols: read("cols", 80), rows: read("rows", 24) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Second WebSocket server, dedicated to the terminal-run PTY transport
|
||||
* (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so
|
||||
@@ -100,9 +117,9 @@ function initPtyWebSocket(server) {
|
||||
const { attach } = require("./lib/pty-attach");
|
||||
ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
||||
|
||||
ptyWss.on("connection", (ws, runId) => {
|
||||
ptyWss.on("connection", (ws, runId, size) => {
|
||||
try {
|
||||
attach(ws, runId, { cols: 80, rows: 24 });
|
||||
attach(ws, runId, size || { cols: 80, rows: 24 });
|
||||
} catch (err) {
|
||||
try {
|
||||
ws.close(1008, err.message);
|
||||
@@ -133,7 +150,7 @@ function initPtyWebSocket(server) {
|
||||
return;
|
||||
}
|
||||
ptyWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ptyWss.emit("connection", ws, match[1]);
|
||||
ptyWss.emit("connection", ws, match[1], sizeFromQuery(url.searchParams));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,4 +220,11 @@ function closeWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket };
|
||||
module.exports = {
|
||||
initWebSocket,
|
||||
initPtyWebSocket,
|
||||
broadcast,
|
||||
getConnectionCount,
|
||||
closeWebSocket,
|
||||
sizeFromQuery,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user