feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)

- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
This commit is contained in:
2026-10-06 11:31:26 +07:00
parent c067abc68f
commit 6c605849d8
104 changed files with 3478 additions and 1396 deletions
-20
View File
@@ -1,20 +0,0 @@
---
name: repo-onboarding
description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning.
---
# Repo Onboarding Skill
## Workflow
- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`.
- Determine target layer: `server/`, `client/`, or docs.
- Identify the minimal file set needed for the task.
- Select verification commands before editing.
## Verification defaults
- Backend: `npm run test:server`
- Frontend: `npm run test:client`
## References
- `references/module-map.md`
- `references/verification-map.md`
@@ -1,4 +0,0 @@
interface:
display_name: "Repo Onboarding"
short_description: "Map architecture, ownership, and verification strategy before coding."
default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task."
@@ -1,8 +0,0 @@
# Module Map
- `server/index.js`: app startup and route mounting.
- `server/routes/*.js`: API contracts and route behavior.
- `server/db.js`: schema and statement layer.
- `server/websocket.js`: live update broadcast path.
- `client/src/pages/`: route-level UI.
- `client/src/components/`: reusable UI primitives.
@@ -1,8 +0,0 @@
# Verification Map
- Backend changes:
- `npm run test:server`
- Frontend changes:
- `npm run test:client`
- Docs-only changes:
- validate command consistency against root `package.json`
-1
View File
@@ -24,5 +24,4 @@ This directory contains all project-scoped Codex extensions:
## Included skills
- `repo-onboarding` — architecture discovery and verification selection
- `release-guard` — release readiness checks
-20
View File
@@ -1,20 +0,0 @@
---
name: repo-onboarding
description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning.
---
# Repo Onboarding Skill
## Workflow
- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`.
- Determine target layer: `server/`, `client/`, or docs.
- Identify the minimal file set needed for the task.
- Select verification commands before editing.
## Verification defaults
- Backend: `npm run test:server`
- Frontend: `npm run test:client`
## References
- `references/module-map.md`
- `references/verification-map.md`
@@ -1,4 +0,0 @@
interface:
display_name: "Repo Onboarding"
short_description: "Map architecture, ownership, and verification strategy before coding."
default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task."
@@ -1,8 +0,0 @@
# Module Map
- `server/index.js`: app startup and route mounting.
- `server/routes/*.js`: API contracts and route behavior.
- `server/db.js`: schema and statement layer.
- `server/websocket.js`: live update broadcast path.
- `client/src/pages/`: route-level UI.
- `client/src/components/`: reusable UI primitives.
@@ -1,8 +0,0 @@
# Verification Map
- Backend changes:
- `npm run test:server`
- Frontend changes:
- `npm run test:client`
- Docs-only changes:
- validate command consistency against root `package.json`
+5 -4
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -31,9 +31,9 @@ dashboard, run it from source, or stay on Node 20.
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x (3.7+ for pane scrollbars) | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
| **tmux** | 3.x (3.7+ for pane scrollbars) | **Required for the run console** — the browser terminal, `agenthub workspace shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub workspace ci status` |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
---
@@ -42,7 +42,7 @@ dashboard, run it from source, or stay on Node 20.
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
backs the run console**. The dashboard shells out to the `tmux` binary and has
no bundled fallback, so without it **Start Run** and `agenthub lanes shell`
no bundled fallback, so without it **Start Run** and `agenthub workspace shell`
fail while everything else keeps working.
```bash
@@ -93,7 +93,7 @@ session explicitly). You can attach to the
exact same pane from a normal terminal:
```bash
agenthub lanes shell # from inside the lane's directory
agenthub workspace shell # from inside the lane's directory
tmux attach -t agenthub-lane-7 # or by name
tmux ls # every dashboard session is prefixed agenthub-lane-
```
+16 -16
View File
@@ -41,7 +41,7 @@ Internal build — all rights reserved.
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub lanes shell`. Which CLI it starts is the workspace's own
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
@@ -52,7 +52,7 @@ Internal build — all rights reserved.
either uncommitted work or the whole branch against its base. There is no
merge/rebase/reset button by design: recovery from those needs a terminal.
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per workspace (`agenthub lanes up|down|logs`).
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
**Configure**
@@ -94,10 +94,10 @@ Internal build — all rights reserved.
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane |
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` workspaces |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node **25** is the one version to avoid: it breaks 6 server tests through a
@@ -108,7 +108,7 @@ v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub lanes shell` fail without it. Everything else
**Start Run** and `agenthub workspace shell` fail without it. Everything else
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
```bash
@@ -134,7 +134,7 @@ curl -s localhost:4820/api/run/tmux # {"available":true}
```
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
@@ -209,9 +209,9 @@ agenthub status # is the dashboard up
agenthub start # start it in the background and wait for healthy
agenthub service install # auto-start on boot via systemd --user (Linux)
agenthub sessions # recent sessions
agenthub lanes # lanes with stage and progress
agenthub lanes pipeline # this lane's pipeline template, or switch it
agenthub lanes node <id> # run ONE pipeline node's own command (no id: list them)
agenthub workspaces # workspaces with stage and progress
agenthub workspace pipeline # this lane's pipeline template, or switch it
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
agenthub stage <name> # declare the current lane's stage
agenthub tail # live event feed
```
@@ -231,8 +231,8 @@ A workspace is a working directory the dashboard watches. Two kinds:
for you to delete yourself.
```bash
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
```
Every workspace also carries the CLI that drives it — `claude` (the default) or
@@ -279,7 +279,7 @@ events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace
move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done.
A workspace's pipeline template can be switched after creation — `agenthub lanes
A workspace's pipeline template can be switched after creation — `agenthub workspace
pipeline <template>` from the terminal, or the pipeline-template picker next to
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
current declared stage against the new template's nodes and warn if it no
@@ -291,10 +291,10 @@ declarations plus shell hooks the dashboard calls. Each workspace gets a slot, a
ports and per-workspace directories derive from it:
```bash
agenthub lanes up # boot the stack of the lane owning this directory
agenthub lanes runtime # slot, ports, service health
agenthub lanes logs api # tail a service log
agenthub lanes down
agenthub workspace up # boot the stack of the lane owning this directory
agenthub workspace runtime # slot, ports, service health
agenthub workspace logs api # tail a service log
agenthub workspace down
```
Services are fully detached, so restarting the dashboard never stops a running
+16 -16
View File
@@ -42,10 +42,10 @@ Internal build — all rights reserved.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
thật, render bằng xterm.js) gắn vào thư mục của workspace — đúng cái TUI bạn thấy ở
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây
`agenthub workspace shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
- **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi
workspace được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
workspace được cấp slot, port và service riêng (`agenthub workspace up|down|logs`).
**Cấu hình**
@@ -76,10 +76,10 @@ Internal build — all rights reserved.
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho workspace dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub workspace ci status` |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
@@ -89,7 +89,7 @@ cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
### Cài tmux
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
thì **Start Run** và `agenthub workspace shell` sẽ lỗi. Phần còn lại (session,
analytics, workspace, stage detection, CLI) vẫn chạy bình thường.
```bash
@@ -115,7 +115,7 @@ curl -s localhost:4820/api/run/tmux # {"available":true}
```
Mỗi workspace chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub workspace
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
@@ -189,9 +189,9 @@ agenthub status # dashboard có đang chạy không
agenthub start # start ngầm (background) và chờ tới khi healthy
agenthub service install # tự khởi động khi boot máy qua systemd --user (Linux)
agenthub sessions # session gần đây
agenthub lanes # danh sách lane kèm stage và progress
agenthub lanes pipeline # pipeline template của lane này, hoặc đổi nó
agenthub lanes node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
agenthub workspaces # danh sách workspace kèm stage và progress
agenthub workspace pipeline # pipeline template của lane này, hoặc đổi nó
agenthub workspace node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
agenthub stage <name> # khai báo stage hiện tại của lane
agenthub tail # xem live event feed
```
@@ -210,8 +210,8 @@ Workspace là một working directory mà dashboard theo dõi. Hai loại:
gọi phải echo lại.
```bash
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
```
Mỗi workspace còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
@@ -236,7 +236,7 @@ từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút),
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
báo, và node suy luận không bao giờ render thành `done`.
Pipeline template của workspace có thể đổi sau khi tạo — `agenthub lanes
Pipeline template của workspace có thể đổi sau khi tạo — `agenthub workspace
pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template
cạnh tiêu đề workspace trong panel chi tiết của trang Workspace. Cả hai đều
re-resolve lại stage đã khai báo hiện tại của workspace theo node-list của template
@@ -248,10 +248,10 @@ workspace, khi repository của nó khai báo một profile tại `<repo>/.agent
được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó:
```bash
agenthub lanes up # boot stack của lane sở hữu thư mục hiện tại
agenthub lanes runtime # slot, port, tình trạng service
agenthub lanes logs api # tail log của một service
agenthub lanes down
agenthub workspace up # boot stack của lane sở hữu thư mục hiện tại
agenthub workspace runtime # slot, port, tình trạng service
agenthub workspace logs api # tail log của một service
agenthub workspace down
```
Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng
+84 -43
View File
@@ -1291,7 +1291,7 @@ async function cmdDoctor() {
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
);
} catch {
@@ -1616,7 +1616,7 @@ async function cmdLanesAdd(args) {
if (repo) {
if (cwd || pipeline) {
console.error(
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
"usage: agenthub workspace add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
);
process.exitCode = 1;
return;
@@ -1653,7 +1653,7 @@ async function cmdLanesAdd(args) {
}
if (!cwd || !title) {
console.error(
"usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
"usage: agenthub workspace add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
);
process.exitCode = 1;
return;
@@ -1704,7 +1704,7 @@ function cmdLanesProfileInit(args) {
const repo = args.find((arg) => !arg.startsWith("--"));
const force = args.includes("--force");
if (!repo) {
console.error("usage: agenthub lanes profile init <repo> [--force]");
console.error("usage: agenthub workspace profile init <repo> [--force]");
process.exitCode = 1;
return;
}
@@ -1746,7 +1746,7 @@ function cmdLanesProfileInit(args) {
console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
for (const todo of result.todos) console.log(` ${todo}`);
}
console.log(`\nNext: agenthub lanes profile check ${repo}`);
console.log(`\nNext: agenthub workspace profile check ${repo}`);
}
/**
@@ -1795,7 +1795,7 @@ async function cmdLanesLifecycle(action, args) {
const fields = LANE_PREFLIGHT_FIELDS[action];
if (!id) {
const extra = action === "reset" ? " [--keep-db]" : "";
console.error(`usage: agenthub lanes ${action} <id> [--force]${extra} --yes`);
console.error(`usage: agenthub workspace ${action} <id> [--force]${extra} --yes`);
process.exitCode = 1;
return;
}
@@ -1834,7 +1834,7 @@ async function cmdLanesLifecycle(action, args) {
preflight.blocked.includes("adopted")
) {
console.error(
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub workspace remove ${id} --yes\` to drop only the dashboard's record of it.`
);
process.exitCode = 1;
return;
@@ -1902,7 +1902,7 @@ async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) {
console.log(
"no workspaces yet — create one with: agenthub lanes add --cwd <path> --title <text>"
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
);
return;
}
@@ -1957,7 +1957,7 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane");
const explicit = flagValue("lane") || flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
@@ -1981,7 +1981,9 @@ async function resolveLaneArg(args) {
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) {
console.error(`no workspace owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
console.error(
`no workspace owns ${cwd} — create one with: agenthub workspace add --cwd ${cwd}`
);
process.exitCode = 1;
return null;
}
@@ -2002,7 +2004,7 @@ async function resolveLaneArg(args) {
console.error(
`${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates
.join("|")}> or --workspace <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
@@ -2020,7 +2022,7 @@ function printRuntime(runtime) {
return;
}
if (!runtime.provisioned) {
console.log("profile found, runtime not provisioned yet — run: agenthub lanes up");
console.log("profile found, runtime not provisioned yet — run: agenthub workspace up");
return;
}
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
@@ -2121,7 +2123,7 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "logs") {
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
if (!svc) {
console.error("usage: agenthub lanes logs [<id>] <service> [--tail bytes]");
console.error("usage: agenthub workspace logs [<id>] <service> [--tail bytes]");
process.exitCode = 1;
return;
}
@@ -2140,7 +2142,7 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "hook") {
const name = laneArgs[0];
if (!name || name.startsWith("--")) {
console.error("usage: agenthub lanes hook [<id>] <name> [args…]");
console.error("usage: agenthub workspace hook [<id>] <name> [args…]");
process.exitCode = 1;
return;
}
@@ -2155,7 +2157,7 @@ async function cmdLanesRuntime(sub, args) {
return;
}
console.log(
`workspace #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
`workspace #${laneId} running hook ${name} — follow it with: agenthub workspace logs ${laneId} ${name}`
);
}
@@ -2191,7 +2193,7 @@ async function cmdLanesRuntime(sub, args) {
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
` then: agenthub lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
` then: agenthub workspace sync-base --continue ${branch ? branch + " " : ""}${laneId}`
);
process.exitCode = 4;
return;
@@ -2437,7 +2439,7 @@ async function cmdLanesMcpSync(args) {
async function cmdLanesIntegration(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: agenthub lanes integration <name> [<id>]");
console.error("usage: agenthub workspace integration <name> [<id>]");
process.exitCode = 1;
return;
}
@@ -2456,13 +2458,13 @@ async function cmdLanesIntegration(args) {
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
async function cmdLanesCi(args) {
if (args[0] !== "status") {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
const ref = args[1];
if (!ref) {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
@@ -2538,7 +2540,7 @@ async function cmdLanesNode(args) {
if (!key) {
listNodes();
console.error(`\nusage: agenthub lanes node <node-id> [extra args…] [<id>] [--print]`);
console.error(`\nusage: agenthub workspace node <node-id> [extra args…] [<id>] [--print]`);
process.exitCode = 1;
return;
}
@@ -2683,7 +2685,7 @@ async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
console.error(
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
"usage: agenthub stage <stage> [--workspace <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
);
process.exitCode = 1;
return;
@@ -2805,82 +2807,86 @@ const COMMAND_GROUPS = [
[
"Workspaces",
[
["lanes", "", "List workspaces with stage, liveness and progress"],
["workspaces", "", "List workspaces with stage, liveness and progress"],
[
"lanes add",
"workspace add",
"--cwd <path> --title <text> [--tool <id>]",
"Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)",
],
[
"lanes add",
"workspace add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree workspace",
],
[
"lanes profile init",
"workspace profile init",
"<repo> [--force]",
"Detect a Node.js project and scaffold .agenthub/profile/",
],
[
"lanes profile check",
"workspace profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a workspace id)",
],
[
"lanes pipeline",
"workspace pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a workspace renders against",
],
[
"lanes node",
"workspace node",
"[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)",
],
[
"lanes shell",
"workspace shell",
"[<id>]",
"Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run",
],
[
"lanes reset|remove|purge",
"workspace reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
],
[
"lanes up|down",
"workspace up|down",
"[<id>] [--no-build] [--qc]",
"Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)",
],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the workspace's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
["workspace runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
[
"lanes sync-base",
"workspace logs",
"[<id>] <svc> [--tail N]",
"Tail one of the workspace's hook or service logs",
],
["workspace hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"workspace sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)",
],
[
"lanes proof-link",
"workspace proof-link",
"[<id>]",
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
],
[
"lanes mcp sync",
"workspace mcp sync",
"[<id>]",
"Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles",
],
[
"lanes integration",
"workspace integration",
"<name> [<id>]",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1",
],
[
"lanes ci status",
"workspace ci status",
"<branch-or-sha> [<id>]",
"One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending",
],
[
"lanes gc",
"workspace gc",
"[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace",
],
@@ -2944,6 +2950,9 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background).
Prefer an interactive session? Run ${c.bold("agenthub repl")} for a shell with completion,
@@ -3352,10 +3361,18 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
// so `help lanes` finds the `workspace …` rows and vice versa.
const canon = (words) => {
const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
};
const want = canon(token);
const matches = [];
for (const [group, items] of COMMAND_GROUPS) {
for (const [name, args, desc] of items) {
if (name === token || name.startsWith(token + " ")) matches.push([group, name, args, desc]);
const have = canon(name);
if (have === want || have.startsWith(want + " ")) matches.push([group, name, args, desc]);
}
}
if (!matches.length) return false;
@@ -3606,6 +3623,26 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word.
* `workspace` is the name to use; `lanes` (the handler's own name) and
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
function warnOldNames(argv) {
const old = [];
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
if (old.length) {
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
}
}
/** All top-level command tokens, derived from COMMAND_GROUPS (first word of
* each invocation) plus the always-available `help`. Used by the REPL
* completer and unknown-command detection so they never drift from help. */
@@ -3615,6 +3652,8 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]);
}
set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set];
})();
@@ -3632,7 +3671,8 @@ const SUBCOMMANDS = {
* for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */
async function runCommand(argv) {
const [cmd, ...rest] = argv;
warnOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest);
switch (cmd) {
case "health":
@@ -3702,7 +3742,7 @@ async function runCommand(argv) {
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
console.error(
"usage: agenthub lanes profile init <repo> [--force] | agenthub lanes profile check [<path>]"
"usage: agenthub workspace profile init <repo> [--force] | agenthub workspace profile check [<path>]"
);
process.exitCode = 1;
return;
@@ -3786,6 +3826,7 @@ async function runCommand(argv) {
* an embedding loop can stay alive.
*/
async function handleServerDown(argv) {
argv = canonicalArgv(argv);
const cmd = argv[0];
const { flags, positional } = parseArgs(argv.slice(1));
const handler = OFFLINE_HANDLERS[cmd];
+12 -8
View File
@@ -81,6 +81,7 @@ import {
import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { WSMessage, ImportProgressMessage } from "../lib/types";
import { PathPickButton } from "./PathPickButton";
type Mode = "rescan" | "path" | "upload" | "backup";
@@ -426,14 +427,17 @@ export function ImportHistory() {
{mode === "path" && (
<div className="space-y-3">
<div>
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder={t("import.folderPlaceholder")}
className="input w-full text-sm font-mono"
spellCheck={false}
/>
<div className="flex gap-1.5">
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder={t("import.folderPlaceholder")}
className="input w-full text-sm font-mono"
spellCheck={false}
/>
<PathPickButton start={folderPath} onPick={setFolderPath} />
</div>
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
</div>
<div className="flex justify-end">
+2 -2
View File
@@ -87,9 +87,9 @@ export function PageTabs({
aria-controls={hasPanel && isActive ? panelId : undefined}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(id)}
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
className={`inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
isActive
? "bg-accent/15 border-accent/30 text-accent font-semibold shadow-[inset_0_-2px_0_rgb(var(--accent))]"
? "bg-accent/15 border-accent/30 text-accent shadow-[inset_0_-2px_0_rgb(var(--accent))]"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
+86
View File
@@ -0,0 +1,86 @@
/**
* @file PathPickButton.tsx
* @description The one "Browse" button beside every path field: it asks the
* server to open the operating system's own folder/file picker
* (`POST /api/lanes/pick-path`) and fills the field with the absolute path the
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
onPick,
showLabel = false,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
const [fallback, setFallback] = useState(false);
const title = kind === "dir" ? t("browse.title") : t("browse.fileTitle");
const pick = async () => {
setBusy(true);
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
};
return (
<>
<button
type="button"
onClick={pick}
disabled={busy}
title={title}
aria-label={title}
className="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
{showLabel && t("browse.button")}
</button>
{/* Its clicks stay here: a backdrop click must close only this modal,
not bubble on to a dialog this button sits in. */}
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
/>
</div>
</>
);
}
+14 -6
View File
@@ -86,6 +86,7 @@ import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope";
import { PathPickButton } from "./PathPickButton";
/** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
@@ -558,12 +559,19 @@ export function RemoteSources() {
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
</label>
<input
className="input w-full font-mono"
placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""}
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
/>
<div className="flex gap-1.5">
<input
className="input w-full font-mono"
placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""}
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
/>
<PathPickButton
kind="file"
start={form.identity_file || "~/.ssh"}
onPick={(p) => setForm((f) => ({ ...f, identity_file: p }))}
/>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-fg-muted mb-1">
+82 -27
View File
@@ -2,25 +2,28 @@
* @file StatusBar — the one-line bar along the bottom of the app shell, on
* every page: the live-connection state (click: the connection details modal,
* which the sidebar owns and which also carries the update check), the version
* — with a green dot while a newer release is out —, how many workspaces are running and how
* many need the user (from the shared lane list, no request of its own), and
* today's spend — the `daily_costs` bucket for the local date from
* `GET /api/pricing/cost`, the Dashboard's own cost call — and, on the
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. No provider quota: reading Claude's quota rotates
* whenever the URL names another workspace or session. Each figure carries its
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { useLanes } from "../hooks/useLanes";
import type { CostResult } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
@@ -30,6 +33,37 @@ function localDate(d = new Date()): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const usd = (n: number) => `$${n.toFixed(2)}`;
/** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded),
* costliest first: the hover text of a cost figure. */
function perModel(r: CostResult): { tokens: number; lines: string } {
const byModel = new Map<string, { tokens: number; cost: number }>();
for (const b of r.breakdown) {
const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 };
m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens;
m.cost += b.cost;
byModel.set(b.model, m);
}
const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost);
return {
tokens: rows.reduce((n, [, m]) => n + m.tokens, 0),
lines: rows
.map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`)
.join("\n"),
};
}
function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) {
const { t } = useTranslation("nav");
const { tokens, lines } = perModel(r);
return (
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
</span>
);
}
export function StatusBar({
wsConnected,
left,
@@ -44,12 +78,12 @@ export function StatusBar({
updateAvailable?: boolean;
}) {
const { t } = useTranslation("nav");
const { lanes } = useLanes();
const running = lanes.filter((l) => l.status === "running").length;
const needYou = lanes.filter((l) => l.needs_action).length;
const [today, setToday] = useState<number | null>(null);
const [laneCost, setLaneCost] = useState<number | null>(null);
const [sessionCost, setSessionCost] = useState<number | null>(null);
const [active, setActive] = useState(0);
const [needYou, setNeedYou] = useState(0);
const running = Math.max(active - needYou, 0);
const [today, setToday] = useState<CostResult | null>(null);
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
const [runs, setRuns] = useState<RunHandle[]>([]);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
@@ -63,6 +97,22 @@ export function StatusBar({
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
useEffect(() => {
let live = true;
// Another workspace or session: never show the previous one's figure.
@@ -71,19 +121,19 @@ export function StatusBar({
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost()
.then((r) => live && setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r.total_cost))
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r.total_cost))
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
@@ -96,7 +146,6 @@ export function StatusBar({
document.removeEventListener("visibilitychange", tick);
};
}, [lane, sessionId]);
const usd = (n: number) => `$${n.toFixed(2)}`;
return (
<footer
@@ -144,19 +193,25 @@ export function StatusBar({
</span>
</span>
{today !== null && (
<span data-testid="status-bar-cost" className="tabular-nums">
{t("statusBar.today", { cost: usd(today) })}
</span>
<CostFigure
testId="status-bar-cost"
label={t("statusBar.today", { cost: usd(today.total_cost) })}
r={today}
/>
)}
{lane !== null && laneCost !== null && (
<span data-testid="status-bar-workspace-cost" className="tabular-nums">
{t("statusBar.workspace", { cost: usd(laneCost) })}
</span>
<CostFigure
testId="status-bar-workspace-cost"
label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
r={laneCost}
/>
)}
{sessionId && sessionCost !== null && (
<span data-testid="status-bar-session-cost" className="tabular-nums">
{t("statusBar.session", { cost: usd(sessionCost) })}
</span>
<CostFigure
testId="status-bar-session-cost"
label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
r={sessionCost}
/>
)}
</footer>
);
+75 -32
View File
@@ -3,9 +3,8 @@
* Desktop's project list: every workspace (lane) on one line in the stored drag
* order (`laneOrderStorage.ts`), each expanding to ITS sessions
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
* workspace opens its tabs (`/workspace?lane=<id>&run=<runId>`, the pane it
* last focused) when it has live ones, else its home (`?lane=<id>`, also what
* `+` on a row opens for a new session). Live status comes from the shared
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
* live tab is opened from its session row. Live status comes from the shared
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
* lane's status or session moves and handed over by the Workspace page's own
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
@@ -14,23 +13,24 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { loadLayout } from "../lib/paneLayout";
import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import {
insertionIndex,
moveBefore,
moveLane,
readLaneOrder,
sortByStoredOrder,
writeLaneOrder,
} from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { WorkspaceRow, workspaceName } from "./lanes/WorkspaceRow";
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { AddLaneModal } from "./lanes/AddLaneModal";
@@ -100,16 +100,6 @@ export function WorkspaceTree() {
};
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
// A row click: a workspace with live tabs opens on them — on the pane it
// last focused — and one with none on its home (the tab bar's Home button
// reaches the home of the other kind).
const open = (id: number) => {
const runs = liveRuns.filter((r) => r.laneId === id && r.status === "running");
const focus = loadLayout(id).focus;
const run = runs.find((r) => (r.slot ?? 0) === focus) ?? runs[0];
if (run) navigate(`/workspace?lane=${id}&run=${encodeURIComponent(run.id)}`);
else select(id);
};
const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim();
const setExpanded = (id: number, open: boolean) =>
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
@@ -142,9 +132,9 @@ export function WorkspaceTree() {
}, [q]);
const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0;
const reorder = (from: number, to: number) => {
const reorder = (move: (ids: number[]) => number[]) => {
const ids = sorted.map((l) => l.id);
const moved = moveLane(ids, from, to);
const moved = move(ids);
if (moved === ids) return;
// Under a tool filter the shown ids go back into the slots they held in
// the full order, so the hidden workspaces keep their places.
@@ -157,6 +147,44 @@ export function WorkspaceTree() {
setOrderTick((n) => n + 1);
};
// A row only starts a drag; the whole section takes the drop, so a gap — a
// workspace's session list, the space above or below a row — lands too.
// `dropAt` is the insertion point in `sorted`, drawn as a line.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
const dropIndexAt = (y: number) => {
const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-lane-index]") ?? [])];
const mids = rows.map((r) => {
const b = r.getBoundingClientRect();
return b.top + b.height / 2;
});
const row = rows[insertionIndex(mids, y)];
return row ? Number(row.dataset.laneIndex) : sorted.length;
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientY);
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
setDropAt(null);
if (!isLaneDrag(e)) return;
e.preventDefault();
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
const before = dropIndexAt(e.clientY);
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
},
};
const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null);
if (title === null || title === (lane.title || "")) return;
@@ -168,17 +196,10 @@ export function WorkspaceTree() {
}
};
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction, runId?: string) => {
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
if (action === "rename") setRenamingId(lane.id);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
// Start opens the session it started, as the home's menu does.
else if (action === "started")
navigate(
runId
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`
: `/workspace?lane=${lane.id}`
);
};
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
@@ -201,7 +222,12 @@ export function WorkspaceTree() {
// ≥24px targets (WCAG 2.5.8); each carries an aria-label.
const headBtn = "btn-icon";
return (
<section data-testid="workspace-tree" className="mt-3 border-t border-border pt-3">
<section
data-testid="workspace-tree"
// The bottom padding is a drop strip below the last workspace.
className="mt-3 border-t border-border pb-6 pt-3"
{...dragHandlers}
>
<div className="mb-1 flex items-center gap-1 px-2">
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
{t("tree.title")}
@@ -264,9 +290,10 @@ export function WorkspaceTree() {
/>
)}
<div className="space-y-px px-1">
<div ref={listRef} className="space-y-px px-1">
{sorted.map((lane, i) => (
<div key={lane.id}>
<div key={lane.id} className="relative">
{dropAt === i && <DropLine />}
{visible(lane) && (
<WorkspaceRow
lane={lane}
@@ -275,12 +302,12 @@ export function WorkspaceTree() {
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
open(lane.id);
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={reorder}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
@@ -300,6 +327,11 @@ export function WorkspaceTree() {
)}
</div>
))}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
</div>
)}
</div>
{error && (
@@ -314,7 +346,7 @@ export function WorkspaceTree() {
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onAction={(a, runId) => onMenuAction(menu.lane, a, runId)}
onAction={(a) => onMenuAction(menu.lane, a)}
/>
)}
{destructive && (
@@ -342,3 +374,14 @@ export function WorkspaceTree() {
</section>
);
}
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
<div
aria-hidden
data-testid="workspace-drop-line"
className="pointer-events-none absolute inset-x-1 -top-px z-10 h-0.5 rounded-full bg-accent-solid"
/>
);
}
@@ -0,0 +1,69 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { PathPickButton } from "../PathPickButton";
vi.mock("../../lib/api", () => ({
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
}));
vi.mock("../../lib/toast", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
const fail = (code: string) => Object.assign(new Error(code), { code });
beforeEach(() => {
vi.mocked(api.lanes.pickPath).mockReset();
vi.mocked(api.lanes.browse)
.mockReset()
.mockResolvedValue({ path: "/tmp", parent: "/", entries: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.error).mockReset();
});
describe("PathPickButton", () => {
it("fills the field with the chosen path and leaves it alone on Cancel", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ path: "/home/u/proj" });
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ cancelled: true });
render(<PathPickButton start=" /home/u " onPick={onPick} />);
const button = screen.getByTitle("Browse for a folder");
await userEvent.click(button);
await waitFor(() => expect(onPick).toHaveBeenCalledWith("/home/u/proj"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("dir", "/home/u");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(onPick).toHaveBeenCalledTimes(1);
});
it("opens the in-app folder browser on any failure, not only a missing picker", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EBUSY"));
render(<PathPickButton onPick={vi.fn()} />);
await userEvent.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByRole("dialog", { name: "Browse for a folder" })).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
expect(screen.queryByRole("dialog")).toBeNull();
});
});
@@ -2,9 +2,9 @@
* @file StatusBar.test.tsx
* @description Tests for the shell's bottom status bar: the live/disconnected
* state with the version (click opens the connection details), the running and
* needs-you counts from the shared lane list, today's spend from the
* pricing cost call's `daily_costs`, and on the Workspace page the open
* workspace's and the shown session's totals.
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
* with its tokens and a per-model hover list, and on the Workspace page the
* open workspace's and the shown session's totals.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,6 +13,8 @@ import { act, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
const M = 1_000_000;
function today() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
@@ -21,24 +23,50 @@ function today() {
vi.mock("../../lib/api", () => ({
api: {
lanes: {
list: vi.fn().mockResolvedValue({
lanes: [
{ id: 1, status: "running", needs_action: null },
{ id: 2, status: "running", needs_action: "permission" },
{ id: 3, status: "idle", needs_action: null },
],
counts: {},
}),
sessions: {
// 3 active sessions, 1 of them waiting on the user.
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
sessions: [],
total: p.awaiting ? 1 : 3,
})),
},
pricing: {
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number }) => ({
total_cost: opts?.lane ? 123.456 : 10,
breakdown: [],
daily_costs: [
{ date: "2000-01-01", cost: 9 },
{ date: today(), cost: 1.234 },
],
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number; date?: string }) => ({
total_cost: opts?.lane ? 123.456 : opts?.date === today() ? 1.234 : 10,
breakdown: opts?.lane
? []
: [
// Two buckets of one model fold into one line.
{
model: "claude-opus-4-8",
input_tokens: 1 * M,
output_tokens: 0,
cache_read_tokens: 10 * M,
cache_write_tokens: 0,
cost: 1,
matched_rule: null,
},
{
model: "claude-opus-4-8",
speed: "fast",
input_tokens: 0,
output_tokens: 1 * M,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.2,
matched_rule: null,
},
{
model: "claude-haiku-4-5",
input_tokens: 300_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.034,
matched_rule: null,
},
],
daily_costs: [],
})),
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
total_cost: id === "s-live" ? 4.5 : 7,
@@ -63,7 +91,13 @@ describe("StatusBar", () => {
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
expect(cost).toHaveAttribute(
"title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
@@ -79,7 +113,7 @@ describe("StatusBar", () => {
it("off the Workspace page shows no workspace or session cost", async () => {
at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
await screen.findByText("Today $1.23");
await screen.findByText(/^Today \$1\.23/);
expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull();
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
});
@@ -89,8 +123,8 @@ describe("StatusBar", () => {
"/workspace?lane=3&session=s-old",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
);
expect(await screen.findByText("Workspace $123.46")).toBeInTheDocument();
expect(await screen.findByText("Session $7.00")).toBeInTheDocument();
expect(await screen.findByText("Workspace $123.46 (0 Tokens)")).toBeInTheDocument();
expect(await screen.findByText(/^Session \$7\.00/)).toBeInTheDocument();
expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 });
});
@@ -99,7 +133,7 @@ describe("StatusBar", () => {
"/workspace?lane=3&run=agenthub-lane-3-1",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
);
await screen.findByText("Workspace $123.46");
await screen.findByText(/^Workspace \$123\.46/);
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
act(() => {
window.dispatchEvent(
@@ -108,6 +142,6 @@ describe("StatusBar", () => {
})
);
});
expect(await screen.findByText("Session $4.50")).toBeInTheDocument();
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
});
});
@@ -81,6 +81,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
@@ -307,7 +308,7 @@ describe("WorkspaceTree", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("shows a live tab whose session has no row yet as a New session, never a shell", async () => {
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
vi.mocked(api.run.list).mockImplementation(
async () =>
({
@@ -341,27 +342,95 @@ describe("WorkspaceTree", () => {
await user.click(await screen.findByTestId("workspace-toggle-1"));
const fresh = await screen.findByTestId("workspace-session-new-agenthub-lane-1");
expect(fresh).toHaveTextContent("New session");
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-3")).toBeNull();
expect(screen.getByTestId("workspace-session-new-agenthub-lane-1-3")).toHaveTextContent(
"Terminal"
);
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-2")).toBeNull();
await user.click(fresh);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
});
it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => {
vi.mocked(api.run.history).mockImplementation(
async () =>
({
items: [
{
id: "agenthub-lane-1-2",
tool: "shell",
status: "running",
started_at: "2026-10-06T10:00:00Z",
ended_at: null,
isLive: true,
},
{
id: "agenthub-lane-1-3@a",
tool: "shell",
status: "killed",
started_at: "2026-10-06T09:00:00Z",
ended_at: "2026-10-06T09:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@b",
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@c",
tool: "shell",
status: "exited",
started_at: "2026-10-06T07:00:00Z",
ended_at: "2026-10-06T07:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@d",
tool: "shell",
status: "exited",
started_at: "2026-10-06T06:00:00Z",
ended_at: "2026-10-06T06:05:00Z",
isLive: false,
},
],
}) as never
);
vi.mocked(api.lanes.action).mockImplementation(
async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a");
expect(closed).toHaveTextContent("Terminal");
expect(api.run.history).toHaveBeenCalledWith(
expect.objectContaining({ laneId: 1, tool: "shell" })
);
// The live one is not history, and only the newest three are listed.
expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull();
expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3);
await user.click(closed);
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" });
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
expect(await screen.findByTestId("workspace-open-2")).toHaveAttribute("aria-current", "true");
// Lane 1 has a live tab: its row opens the tab, not the home.
// Lane 1 has a live tab: its row still opens the home.
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
expect(await screen.findByTestId("workspace-sessions-1")).toBeInTheDocument();
// Lane 2 has none: its home.
await user.click(screen.getByTestId("workspace-open-2"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=2");
});
it("opens a workspace on the pane it last focused, and follows the page's run list", async () => {
it("opens a workspace's home even while it has live tabs", async () => {
vi.mocked(api.run.list).mockImplementation(async () => ({ items: [] }) as never);
localStorage.setItem(
"agenthub.workspace.paneLayout",
@@ -393,7 +462,7 @@ describe("WorkspaceTree", () => {
);
});
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
});
it("reorders with Alt+ArrowUp and keeps the order in localStorage", async () => {
@@ -21,6 +21,7 @@ import { api, type CwdSuggestion } from "../../lib/api";
import { confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { PathPickButton } from "../PathPickButton";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
@@ -133,15 +134,18 @@ export function ProjectMemoryModal({
>
{t("projectMemory.dirLabel")}
</label>
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<div className="flex gap-1.5">
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<PathPickButton start={dir} onPick={setDir} />
</div>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.path} value={s.path}>
+352 -325
View File
@@ -8,21 +8,21 @@
* manually-typed branch name. The user also picks which CLI drives the lane
* (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by browsing (`FolderBrowseModal`) — a native folder picker
* cannot hand a web page an absolute filesystem path, so browsing is
* server-backed instead. The worktree lane returned is `status: "provisioning"`:
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
* The worktree lane returned is `status: "provisioning"`:
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal";
import { ChevronDown, X } from "lucide-react";
import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
@@ -124,7 +124,6 @@ export function AddLaneModal({
const noTools = aiTools.loaded && aiTools.defaultId === null;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed";
mcp: "ok" | "failed";
@@ -306,23 +305,18 @@ export function AddLaneModal({
}
};
// ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s
// identity. Every keystroke in the fields below re-renders this component; an
// inline `() => {...}` handed to `onCancel` would get a new identity each
// time, re-running that effect and yanking focus off the field being typed
// into after the very first character. useCallback keeps the identity stable
// across renders so only mount/unmount (and a real onClose change) refocuses.
// Escape, the backdrop, the X and Cancel all land here. A folder browser
// open on top answers its own Escape and backdrop (PathPickButton).
const handleCancel = useCallback(() => {
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
// While the folder browser is open on top of it, that dismissal should
// close only the browser, not both modals at once.
if (browseOpen) {
setBrowseOpen(false);
return;
}
reset();
onClose();
}, [onClose, browseOpen]);
}, [onClose]);
// Cancel takes focus on open, the safer default.
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
useDialog(open, panelRef, handleCancel, cancelRef);
// A summary on screen means the lane already exists and a step failed, so
// the confirm button has no second creation to run: it closes instead. That
@@ -341,336 +335,369 @@ export function AddLaneModal({
);
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
if (!open) return null;
// Its own shell, not ConfirmModal: that one lays its body beside the title in
// the X button's row, which left this form narrower than its own footer.
return (
<ConfirmModal
open={open}
title={t("addLane")}
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={disabled}
onConfirm={done ? handleCancel : submit}
onCancel={handleCancel}
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
onClick={handleCancel}
role="presentation"
>
<div className="space-y-3">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={mode === "repo"}
label={t("mode.repo")}
title={t("mode.repoHint")}
onClick={() => setMode("repo")}
/>
<Seg
active={mode === "worktree"}
label={t("mode.worktree")}
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
<div
ref={panelRef}
className="relative flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
{t("addLane")}
</h3>
<button
type="button"
onClick={handleCancel}
className="btn-icon -mr-1"
aria-label={t("destructive.cancel")}
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings/agents" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
) : (
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
</div>
<button
type="button"
onClick={() => setBrowseOpen(true)}
title={t("browse.title")}
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
>
<FolderOpen className="h-3.5 w-3.5" />
{t("browse.button")}
</button>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={mode === "repo"}
label={t("mode.repo")}
title={t("mode.repoHint")}
onClick={() => setMode("repo")}
/>
<Seg
active={mode === "worktree"}
label={t("mode.worktree")}
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
</p>
</div>
{/* What this repo already has checked out. Without it the form asked
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings/agents" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
) : (
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
</div>
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
</p>
</div>
{/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */}
{mode === "worktree" && worktrees.length > 0 && (
<div
data-testid="add-lane-worktrees"
className="rounded-md border border-border-light bg-surface-0 p-2"
>
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
{t("addLaneWorktreesTitle", { count: worktrees.length })}
</p>
<ul className="space-y-0.5">
{worktrees.slice(0, 8).map((w) => (
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
<span className="shrink-0 font-mono text-fg-primary">
⑂ {w.branch || t("addLaneWorktreeDetached")}
</span>
<span className="truncate text-fg-muted" title={w.path}>
{w.path}
</span>
</li>
))}
{worktrees.length > 8 && (
<li className="text-[10px] text-fg-muted">
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
</li>
)}
</ul>
{mode === "worktree" && worktrees.length > 0 && (
<div
data-testid="add-lane-worktrees"
className="rounded-md border border-border-light bg-surface-0 p-2"
>
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
{t("addLaneWorktreesTitle", { count: worktrees.length })}
</p>
<ul className="space-y-0.5">
{worktrees.slice(0, 8).map((w) => (
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
<span className="shrink-0 font-mono text-fg-primary">
⑂ {w.branch || t("addLaneWorktreeDetached")}
</span>
<span className="truncate text-fg-muted" title={w.path}>
{w.path}
</span>
</li>
))}
{worktrees.length > 8 && (
<li className="text-[10px] text-fg-muted">
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
</li>
)}
</ul>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
</label>
<input
id="add-lane-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
</label>
<input
id="add-lane-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
{mode === "worktree" && branches && (
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
project usually wants the lane to BE the repo, working on
whatever is checked out (`main`) — which is exactly Repo
mode, so this jumps there rather than inventing a second way
to do it. git cannot check `main` out in a second worktree
while the repo itself holds it, so a worktree lane could not
offer this even if it wanted to. */}
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
// No commit, nothing to fork: the server refuses this with
// EEMPTYREPO, so the one way forward is offered instead.
<div className="space-y-1">
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
<button
type="button"
onClick={() => setMode("repo")}
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
// No commit, nothing to fork: the server refuses this with
// EEMPTYREPO, so the one way forward is offered instead.
<div className="space-y-1">
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
<button
type="button"
onClick={() => setMode("repo")}
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
>
{t("addLaneNoBranchesUseRepo")}
</button>
</div>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="select w-full !py-1.5 !text-xs"
>
{t("addLaneNoBranchesUseRepo")}
</button>
</div>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
<div>
<label
className="mb-1 block text-xs text-fg-secondary"
htmlFor="add-lane-branch"
>
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label
className="mb-1 block text-xs text-fg-secondary"
htmlFor="add-lane-existing"
>
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="select w-full !py-1.5 !text-xs"
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="select w-full !py-1.5 font-mono !text-xs"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="select w-full !py-1.5 font-mono !text-xs"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
)}
</div>
)}
)}
</div>
)}
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
{(
[
{
label: t("addLaneSetupProfile"),
ok: setupResult.profile !== "failed",
skipped: setupResult.profile === "skipped",
},
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
] as const
).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
<span
className={
"skipped" in row && row.skipped
? "text-fg-muted"
: row.ok
? "text-status-success"
: "text-status-danger"
}
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
{(
[
{
label: t("addLaneSetupProfile"),
ok: setupResult.profile !== "failed",
skipped: setupResult.profile === "skipped",
},
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
] as const
).map((row) => (
<p
key={row.label}
className="flex items-center gap-1.5 text-[11px] text-fg-primary"
>
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
</span>
{row.label}
</p>
))}
</div>
)}
<span
className={
"skipped" in row && row.skipped
? "text-fg-muted"
: row.ok
? "text-status-success"
: "text-status-danger"
}
>
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
</span>
{row.label}
</p>
))}
</div>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button
ref={cancelRef}
type="button"
onClick={handleCancel}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
onClick={done ? handleCancel : submit}
disabled={busy || disabled}
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
</button>
</div>
</div>
<FolderBrowseModal
open={browseOpen}
initialPath={sourceRepo.trim() || undefined}
onSelect={setSourceRepo}
onClose={() => setBrowseOpen(false)}
/>
</ConfirmModal>
</div>
);
}
@@ -6,7 +6,8 @@
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
* Workspace right panel's Files tab; the other four are its
@@ -131,6 +132,33 @@ function Delta({
);
}
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
function ImageView({ src, path }: { src: string; path: string }) {
const { t } = useTranslation("lanes");
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
return (
<div
data-testid="lane-file-image"
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
>
{failed ? (
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
) : (
<img
src={src}
alt={path}
onError={() => setFailed(true)}
className="max-h-full max-w-full object-contain"
/>
)}
</div>
);
}
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
* key naming it, so the reader is never left with a button that does nothing. */
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
@@ -482,7 +510,7 @@ export function LaneChangesPanel({
(text: string) => {
const runId = pasteToLane(laneId, text);
if (runId) {
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0);
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
}
return runId !== null;
@@ -495,6 +523,8 @@ export function LaneChangesPanel({
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
// An image opens as a picture, never as text or a binary diff.
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
@@ -1683,7 +1713,7 @@ export function LaneChangesPanel({
}}
/>
) : mode === "file" ? (
current || (whole && text) ? (
current || (whole && text) || openImage ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1 flex items-baseline gap-1.5">
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
@@ -1714,6 +1744,7 @@ export function LaneChangesPanel({
only the diff, which has no whole text to click, keeps
an Edit button. */}
{range !== "commit" &&
!openImage &&
(draft === null && whole && text && !text.sha ? (
<span
data-testid="lane-file-noedit"
@@ -1766,7 +1797,16 @@ export function LaneChangesPanel({
</>
))}
</div>
{whole && draft !== null ? (
{openImage ? (
<ImageView
path={openImage}
src={api.lanes.gitRawUrl(
target,
openImage,
range === "commit" && sha ? sha : "work"
)}
/>
) : whole && draft !== null ? (
<textarea
ref={editorRef}
data-testid="lane-file-editor"
+105 -53
View File
@@ -1,7 +1,9 @@
/**
* @file The top of the Changes panel: the lane's working-copy line (branch,
* head, subject, and the uncommitted counts that open a per-file hover panel)
* and the git action bar under it (`LaneGitBar`).
* head, subject, and the uncommitted counts) and the git action bar under it
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
* the open panel lists those files itself, so its header chip opens nothing.
*
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
@@ -101,6 +103,7 @@ function LaneGitFilesPanel({
files,
hidden,
anchor,
side = "below",
onPointerEnter,
onPointerLeave,
}: {
@@ -108,6 +111,8 @@ function LaneGitFilesPanel({
hidden: number;
/** The counts button this panel hangs off. */
anchor: HTMLElement | null;
/** `left` beside a chip on the right-edge rail, where below would cover it. */
side?: "below" | "left";
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
@@ -122,13 +127,20 @@ function LaneGitFilesPanel({
const a = anchor.getBoundingClientRect();
const p = panelRef.current.getBoundingClientRect();
const gap = 4;
if (side === "left") {
setPos({
top: Math.max(8, Math.min(a.top, window.innerHeight - p.height - 8)),
left: Math.max(8, a.left - p.width - gap),
});
return;
}
const top =
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
setPos({
top: Math.max(8, top),
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
});
}, [anchor, files]);
}, [anchor, files, side]);
// Totals are over the LISTED files only — the cap means the rest are unknown,
// and a total that silently omits them would be a wrong number rather than a
// partial one. `hidden` says how many are missing.
@@ -204,6 +216,95 @@ function LaneGitFilesPanel({
);
}
/**
* The uncommitted counts as a chip. With `details` it opens the per-file panel
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
* else lists the files. In the open panel the list below already does, so the
* header shows the chip alone.
*/
export function LaneGitCounts({
git,
vertical = false,
details = true,
}: {
git: Extract<LaneGitFacts, { available: true }>;
vertical?: boolean;
details?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
// Closing is DELAYED: the panel is portalled away from the trigger, so the
// pointer leaves the trigger before it can land on the panel it is heading
// for. Any enter — trigger or panel — cancels the pending close.
const closeTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(closeTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
if (git.dirty === 0 && git.untracked === 0) return null;
const label = t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked });
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
}`;
if (!details) {
return (
<span data-testid="lane-git-counts" className={chip}>
{label}
</span>
);
}
return (
<>
<button
type="button"
ref={setAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className={`cursor-help ${chip}`}
>
{label}
</button>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={anchor}
side={vertical ? "left" : "below"}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</>
);
}
/** The counts on the closed Changes rail: its own facts read, polled only
* while the rail is shown (the open panel owns the read otherwise). */
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
const [git] = useLaneGitFacts(laneId, session);
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
}
export function LaneGitHeader({
laneId,
session,
@@ -220,23 +321,6 @@ export function LaneGitHeader({
/** See `LaneGitBar`'s prop of the same name. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
// Closing is DELAYED: the panel is portalled away from the trigger, so the
// pointer leaves the trigger before it can land on the panel it is heading
// for. Any enter — trigger or panel — cancels the pending close.
const filesCloseTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
if (!git?.available) return null;
const lead = (
<>
@@ -255,30 +339,7 @@ export function LaneGitHeader({
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<button
type="button"
ref={setCountsAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</button>
)}
<LaneGitCounts git={git} details={false} />
</>
);
return (
@@ -291,15 +352,6 @@ export function LaneGitHeader({
onNewLane={onNewLane}
lead={lead}
/>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div>
);
}
+10 -11
View File
@@ -1,7 +1,7 @@
/**
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* session in the first free slot (0–9, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside
@@ -82,6 +82,7 @@ export {
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -110,9 +111,8 @@ function HomeBody({
onRenamed,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here.
* `started` comes with the run it opened. */
onMenuAction: (action: WorkspaceMenuAction, runId?: string) => void;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
}) {
@@ -957,6 +957,7 @@ function HomeBody({
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
@@ -1016,10 +1017,10 @@ function HomeBody({
<div
role="tablist"
aria-label={t("home.memory")}
// shrink-0: a horizontal scroller's min-height is 0, so in this
// height-capped column a long file squeezed the row and clipped
// the tabs. The panel below is the one part that scrolls.
className="mb-3 flex shrink-0 gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
@@ -1124,9 +1125,7 @@ function HomeBody({
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a, runId) =>
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
/>
)}
</div>
+9 -28
View File
@@ -6,8 +6,8 @@
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row. Only a workspace row's own drag (`LANE_DRAG_TYPE`) is taken —
* a file, a tab or a text dragged over is not a reorder. F2 renames too; the
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
* dot's meaning is screen-reader text, and an idle dot is a ring.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,7 +20,7 @@ import { isSubmitEnter } from "../../lib/keys";
/** What a workspace row's drag carries: its index in the list. */
export const LANE_DRAG_TYPE = "application/x-agenthub-lane";
const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
export const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE);
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
@@ -78,7 +78,6 @@ export function WorkspaceRow({
}) {
const { t } = useTranslation("lanes");
const [draft, setDraft] = useState("");
const [dropTarget, setDropTarget] = useState(false);
const name = workspaceName(lane);
const openMenu = (e: MouseEvent) => {
e.preventDefault();
@@ -92,38 +91,20 @@ export function WorkspaceRow({
return (
<div
data-testid={`workspace-row-${lane.id}`}
data-drop-target={dropTarget ? "true" : undefined}
data-lane-index={index}
draggable={!renaming}
onDragStart={(e) => {
// Firefox starts no drag at all without a setData call.
e.dataTransfer.setData(LANE_DRAG_TYPE, String(index));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
setDropTarget(false);
if (!isLaneDrag(e)) return;
e.preventDefault();
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
if (Number.isInteger(from)) onReorder(from, index);
}}
onContextMenu={openMenu}
className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${
dropTarget
? "bg-accent/20"
: selected
? "bg-accent/10"
: lane.needs_action
? "bg-status-warning/10 hover:bg-status-warning/20"
: "hover:bg-surface-3"
selected
? "bg-accent/10"
: lane.needs_action
? "bg-status-warning/10 hover:bg-status-warning/20"
: "hover:bg-surface-3"
}`}
>
<button
@@ -1,29 +1,20 @@
/**
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start (`POST /api/lanes/:id/start`, the free tab the server
* picks — then the session opens, `onAction("started", runId)`) / Stop
* (`stop`, every tab of the workspace — counted from `GET /api/run` when the
* menu opens, and confirmed: "Stop N sessions?"),
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
export function WorkspaceRowMenu({
lane,
@@ -36,62 +27,12 @@ export function WorkspaceRowMenu({
x: number;
y: number;
onClose: () => void;
/** Items the tree itself carries out (rename field, modals, selection);
* `started` comes with the run it opened. */
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
/** Items the tree itself carries out (rename field, modals). */
onAction: (action: WorkspaceMenuAction) => void;
}) {
const { t } = useTranslation("lanes");
// Every live tab of the workspace — slot 0 alone undercounts a split.
const [liveCount, setLiveCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
api.run
.list()
.then(
(r) =>
alive &&
setLiveCount(
(r.items ?? []).filter((x) => x.laneId === lane.id && x.status === "running").length
)
)
.catch(() => undefined);
return () => {
alive = false;
};
}, [lane.id]);
const live = liveCount === null ? !!lane.run_id || lane.status === "running" : liveCount > 0;
const stop = async () => {
onClose();
const n = liveCount ?? 1;
if (
!(await confirmDialog({
title: t("menu.stopTitle", { count: n }),
message: t("menu.stopBody"),
confirmLabel: t("menu.stop"),
destructive: true,
}))
)
return;
try {
await api.lanes.action(lane.id, "stop");
} catch (err) {
toast.error(err);
}
};
const start = async () => {
onClose();
try {
onAction("started", await startInFreeTab(lane.id, { prompt: "" }));
} catch (err) {
toast.error(err);
}
};
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem onSelect={() => void (live ? stop() : start())}>
{live ? t("menu.stop") : t("menu.start")}
</MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem
@@ -11,7 +11,10 @@
* carrying its session id) opens that tmux pane on the Workspace page
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
* session has no row yet — a bare tab before its first prompt — is listed as
* **New session** from the run itself; Ctrl/Cmd+click opens
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -28,10 +31,12 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch } from "lucide-react";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
@@ -41,6 +46,8 @@ import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
/** Closed Terminal tabs listed under a workspace's sessions. */
export const CLOSED_TERMINALS = 3;
/**
* The live pane holding a session: the run the hooks bound it to
@@ -253,12 +260,43 @@ export function WorkspaceSessionList({
const laneRuns = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell"
);
const liveShells = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool === "shell"
);
// The lane's own session/status/needs-you move on every hook, which is when
// a session can appear or change state, and a tab opening or getting its
// session is too — re-read then, not on a timer.
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
.join(",")}`;
// A Terminal tab closing is when one joins the history.
const shellKey = liveShells.map((r) => r.id).join(",");
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
useEffect(() => {
if (hidden) return;
let live = true;
api.run
// Live ones come back too (isLive): ask for enough to fill the list anyway.
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
.then(
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
)
.catch(() => undefined);
return () => {
live = false;
};
}, [lane.id, shellKey, hidden]);
const reopenShell = async (split: boolean) => {
try {
const runId = await startInFreeTab(lane.id, { shell: true });
navigate(
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
split ? { state: { split: true } } : undefined
);
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -301,11 +339,15 @@ export function WorkspaceSessionList({
const fresh =
sessions === null || q
? []
: laneRuns.filter(
(r) =>
!r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
);
: [
...laneRuns.filter(
(r) =>
!r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
),
// Live Terminal tabs: no session row ever, so listed from the run.
...liveShells,
];
const count = sessions === null ? null : shown.length;
useEffect(() => {
if (count !== null) onMatches?.(count);
@@ -380,7 +422,9 @@ export function WorkspaceSessionList({
{error}
</p>
);
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
// Not during a search, like the live ones: they have no name to match.
const closed = q ? [] : closedShells;
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
return (
<>
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
@@ -405,7 +449,7 @@ export function WorkspaceSessionList({
)
}
onContextMenu={(e) => ctx.open(e, r)}
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`}
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
r.id === selectedRunId
? "bg-accent/10 text-fg-primary"
@@ -414,11 +458,20 @@ export function WorkspaceSessionList({
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
{r.tool === "shell" ? (
<>
<span className="truncate">{t("tabs.terminal")}</span>
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
</>
) : (
<>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</>
)}
</button>
</li>
))}
@@ -521,6 +574,28 @@ export function WorkspaceSessionList({
</li>
);
})}
{closed.map((r) => (
<li key={r.id}>
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<span
aria-hidden
className="h-1.5 w-1.5 shrink-0 rounded-full border border-fg-muted"
/>
<span className="sr-only">{t("tree.state.idle")}</span>
<span className="truncate">{t("tabs.terminal")}</span>
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)}
</span>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
</button>
</li>
))}
</ul>
{more > 0 && !q && (
<button
@@ -26,6 +26,7 @@ vi.mock("../../../lib/api", () => ({
worktree: vi.fn(),
ensure: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
get: vi.fn(),
profileInit: vi.fn(),
mcpSync: vi.fn(),
@@ -147,6 +148,7 @@ beforeEach(() => {
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
// provisioning-in-progress race override this per-test.
vi.mocked(api.lanes.get)
@@ -549,7 +551,45 @@ describe("AddLaneModal — repo mode (adopt)", () => {
});
describe("AddLaneModal — folder browse", () => {
const noNativePicker = () =>
vi
.mocked(api.lanes.pickPath)
.mockRejectedValue(Object.assign(new Error("no picker"), { code: "ENODIALOG" }));
it("fills the path field from the OS's own picker", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await waitFor(() =>
expect(document.getElementById("add-lane-repo")).toHaveValue("/Users/tester/app")
);
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("falls back to the in-app browser when no native picker is available", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
const browser = await screen.findByRole("dialog", { name: "Browse for a folder" });
// Its backdrop closes only the browser, never the modal behind it.
await user.click(browser.parentElement!);
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: "/Users",
@@ -566,6 +606,7 @@ describe("AddLaneModal — folder browse", () => {
});
it("Escape closes only the folder browser, not the whole modal", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
@@ -34,6 +34,8 @@ vi.mock("../../../lib/api", () => ({
gitAction: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
`/api/lanes/${id}/git/raw?path=${encodeURIComponent(path)}&rev=${rev}`,
gitSaveFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
@@ -380,6 +382,32 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
});
it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "logo.png", path: "logo.png", dir: false, status: "" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "logo.png",
rev: "work",
binary: true,
truncated: false,
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("logo.png"));
const img = within(await screen.findByTestId("lane-file-image")).getByRole("img");
expect(img).toHaveAttribute("src", "/api/lanes/7/git/raw?path=logo.png&rev=work");
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.queryByTestId("lane-file-noedit")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -1,7 +1,8 @@
/**
* @file LaneGitHeader.test.tsx
* @description Pins the Changes panel's working-copy line: the live branch,
* head, subject and uncommitted counts, the per-file hover panel behind the
* head, subject and uncommitted counts (no hover there — the panel lists the
* files below), the per-file hover panel behind the closed rail's vertical
* counts, that a lane switch drops the previous lane's facts at once, and that
* an unreadable repo renders nothing rather than an error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -9,7 +10,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { LaneGitHeader, LaneGitRailCounts, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
@@ -70,9 +71,9 @@ describe("LaneGitHeader", () => {
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
});
it("names the files behind the counts on hover, with each one's line delta", async () => {
it("names the files behind the rail's counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
render(<LaneGitRailCounts laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
// Hidden until asked for: the row is a one-line summary until you hover.
@@ -98,7 +99,7 @@ describe("LaneGitHeader", () => {
it("stays open while the pointer is inside the panel", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
render(<LaneGitRailCounts laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
@@ -115,12 +116,27 @@ describe("LaneGitHeader", () => {
it("says how many files the server-side cap left out", async () => {
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
render(<Harness laneId={1} />);
render(<LaneGitRailCounts laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
});
it("shows the counts in the open panel's header without the hover list", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
fireEvent.focus(counts);
expect(screen.queryByTestId("lane-git-files")).toBeNull();
});
it("draws the rail's counts vertical", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneGitRailCounts laneId={1} />);
expect((await screen.findByTestId("lane-git-counts")).className).toContain("vertical-rl");
});
it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<Harness laneId={1} />);
@@ -221,7 +221,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
});
it("says so when the server finds all four panes busy", async () => {
it("says so when the server finds all ten sessions in use", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
@@ -229,7 +229,7 @@ describe("SessionResumeButton", () => {
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
});
it("opens the pane the server names when the session is already live there", async () => {
@@ -193,8 +193,9 @@ describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0);
expect(freeSlot(1, runs)).toBe(1);
const all = [0, 1, 2, 3].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
expect(freeSlot(2, all)).toBe(0);
expect(paneRunId(4, 0)).toBe("agenthub-lane-4");
expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2");
@@ -318,7 +319,7 @@ describe("WorkspaceHome", () => {
});
});
it("says so when the server finds all four panes busy", async () => {
it("says so when the server finds all ten sessions in use", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
@@ -327,7 +328,7 @@ describe("WorkspaceHome", () => {
await screen.findByTestId("workspace-recent-s-old");
await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start"));
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
});
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
+7 -5
View File
@@ -1,14 +1,15 @@
/**
* @file startSession.ts — starting a session in a workspace's first free tab
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu
* can start a session without pulling the whole home (and react-markdown) into
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's session
* list can use them without pulling the whole home (and react-markdown) into
* the main bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import i18n from "../../i18n";
import { api, type RunHandle } from "../../lib/api";
import { MAX_SLOTS } from "../../lib/paneLayout";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
@@ -31,7 +32,7 @@ export async function startInFreeTab(
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
export function laneOfRun(runId: string): number | null {
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId);
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
return m ? Number(m[1]) : null;
}
@@ -54,10 +55,11 @@ export async function startInFreeTabDetailed(
}
}
/** The first pane slot with no live run, or null when all four are busy. */
/** The first pane slot with no live run, or null when all ten are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
return null;
}
+248 -94
View File
@@ -1,31 +1,38 @@
/**
* @file ConsoleTabs.tsx
* @description The Workspace session view's tab bar: one tab per live tmux
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's
* `?run=`. A tab is named by its session (`sessions.name`, double-click or
* Rename to change it — `PATCH /api/sessions/:id`), else the prompt it started
* on, else its tool and number, and carries a state dot (waiting for you /
* error / running, with screen-reader text). A tab's X stops its session
* (`DELETE /api/run/:id`) — after a confirm for an AI tab, since it may be
* mid-turn; a shell closes at once. Arrow keys, Home and End move between tabs
* (one tab stop, APG). Errors are toasts. The `+` menu opens a new tab in the
* free slot the server picks (`slot: "auto"`) — a plain shell
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI
* tool started bare on its default model, no prompt (a prompt, router or
* worktree is still the workspace home's prompt box) — plus a link to
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
* bar is the console's only header: Home (the workspace's home) at its left
* end, `actions` (the single pane's split menus) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked. A
* right-click on a tab opens the same actions as a menu (`ContextMenu`).
* @description The Workspace session view's tab bar. Every tab is a GROUP of
* one to four split panes (`PaneGroups`, `lib/paneLayout.ts`), each pane a
* live tmux slot of the workspace (0–9, `GET /api/run`); the active tab is the
* one on screen. A one-pane tab is named by its session (`sessions.name`,
* double-click or Rename to change it — `PATCH /api/sessions/:id`), else the
* prompt it started on, else its tool and number; a combo tab shows every
* pane's icon and name. A tab carries a state dot (waiting for you / error /
* running — a combo shows the most urgent, with screen-reader text). A tab's
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
* keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, router or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
* a `+` item) brings it into the tab on screen, split to the right, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
@@ -42,7 +49,15 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { RunIcon, startPaneDrag } from "./PaneGrid";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
detachSlot,
groupOf,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -113,11 +128,27 @@ export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
};
}
/** Drag data of a whole tab (its index), which only reorders the bar — a
* one-pane tab drags as its pane instead (`PANE_DRAG_TYPE`). */
const TAB_DRAG_TYPE = "application/x-agenthub-tab";
/** One tab: a group's index, slots and the runs the list knows of them. */
interface Tab {
index: number;
key: string;
slots: number[];
runs: RunHandle[];
/** The group's focused pane's run. */
focus: RunHandle;
}
const URGENCY: RunState[] = ["awaiting", "error", "running"];
export function ConsoleTabs({
lane,
tabs,
activeRunId,
shownRunIds = [],
tabs: runs,
groups,
onGroupsChange,
onOpen,
onClosed,
onHome,
@@ -128,15 +159,16 @@ export function ConsoleTabs({
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
/** The tabs: groups of panes, the active one on screen. */
groups: PaneGroups;
/** Reorders the tabs or takes a pane out into a tab of its own. */
onGroupsChange: GroupsUpdate;
/** The sessions the tabs hold, by id: their names and states. */
sessions?: Map<string, Session>;
/** A tab's session was renamed: re-read the sessions. */
onRenamed?: () => void;
/** The focused pane's run. */
activeRunId: string | null;
/** Runs the other panes show. */
shownRunIds?: string[];
/** `split`: Ctrl/Cmd+click — open beside the focused pane. */
/** Shows the tab holding `runId`; `split` (Ctrl/Cmd+click) brings it into
* the tab on screen instead. */
onOpen: (runId: string, split?: boolean) => void;
/** A tab's session was stopped. */
onClosed: (runId: string) => void;
@@ -154,7 +186,78 @@ export function ConsoleTabs({
const [draft, setDraft] = useState("");
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<RunHandle>();
const tabCtx = useContextMenu<Tab>();
// A group whose runs the list has not caught up with yet is not drawn.
const tabs = useMemo<Tab[]>(
() =>
groups.groups.flatMap((l, index) => {
const slots = leaves(l.root);
const own = slots
.map((s) => runs.find((r) => (r.slot ?? 0) === s))
.filter((r): r is RunHandle => !!r);
if (!own.length) return [];
const focus = own.find((r) => (r.slot ?? 0) === l.focus) ?? (own[0] as RunHandle);
return [{ index, key: slots.join("-"), slots, runs: own, focus }];
}),
[groups, runs]
);
// A tab or a pane header dragged along the bar: `dropAt` is the insertion
// point among the drawn tabs, from the pointer against their middles.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
const isBarDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types;
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
};
const dropIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return insertionIndex(
els.map((el) => {
const b = el.getBoundingClientRect();
return b.left + b.width / 2;
}),
x
);
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientX);
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
setDropAt(null);
if (!isBarDrag(e)) return;
e.preventDefault();
// The drawn position, as an index into the groups.
const drawn = dropIndexAt(e.clientX);
const at = tabs[drawn]?.index ?? groups.groups.length;
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
if (tab !== "") {
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
return;
}
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
if (raw === "") return;
const slot = Number(raw);
onGroupsChange((g) => {
const from = groupOf(g, slot);
if (from < 0) return g;
// A pane of a combo leaves it; a one-pane tab just moves.
return leaves(g.groups[from]?.root ?? null).length > 1
? detachSlot(g, slot, at)
: reorderGroups(g, from, at);
});
},
};
useEffect(() => {
if (!menuOpen) return;
@@ -170,11 +273,17 @@ export function ConsoleTabs({
};
}, [menuOpen]);
const slot = freeSlot(lane.id, tabs);
const slot = freeSlot(lane.id, runs);
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
const toolLabel = (r: RunHandle) =>
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
// A combo's dot is its most urgent AI pane's; a shell has none.
const tabState = (tab: Tab): RunState | null => {
const states = tab.runs.filter((r) => r.tool !== "shell").map((r) => runState(sessionOf(r)));
return URGENCY.find((x) => states.includes(x)) ?? null;
};
const toolIds = newTabTools(lane, aiTools);
const open = async (body: Record<string, unknown>, split = false) => {
@@ -187,23 +296,29 @@ export function ConsoleTabs({
}
};
// An AI tab may be mid-turn: stopping it asks first. A shell holds no agent.
const close = async (r: RunHandle) => {
// An AI session may be mid-turn: stopping a tab holding one asks first. A
// shell holds no agent.
const close = async (tab: Tab) => {
if (
r.tool !== "shell" &&
tab.runs.some((r) => r.tool !== "shell") &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(r) }),
title:
tab.runs.length > 1
? t("tabs.stopGroupTitle", { count: tab.runs.length })
: t("tabs.stopTitle", { name: label(tab.focus) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
toast.error(err);
for (const r of tab.runs) {
try {
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
toast.error(err);
}
}
};
@@ -243,22 +358,28 @@ export function ConsoleTabs({
: null;
if (to === null || !tabs[to]) return;
e.preventDefault();
tabRefs.current.get(tabs[to].id)?.focus();
tabRefs.current.get(tabs[to].key)?.focus();
};
const tabStop = tabs.some((r) => r.id === activeRunId) ? activeRunId : tabs[0]?.id;
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
// A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
const r = tabCtx.menu?.item;
const pickTab = (fn: (tab: Tab) => unknown) => () => {
const tab = tabCtx.menu?.item;
tabCtx.close();
if (r) fn(r);
if (tab) fn(tab);
};
const ctxTab = tabCtx.menu?.item;
const ctxRun = ctxTab?.runs.length === 1 ? ctxTab.focus : null;
const item =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return (
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1">
// The whole bar takes a tab's drop: past the last tab is the end.
<div
className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1"
{...dragHandlers}
>
{onHome && (
<button
type="button"
@@ -271,15 +392,21 @@ export function ConsoleTabs({
<House className="h-4 w-4" />
</button>
)}
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
{tabs.map((r, i) => {
const active = r.id === activeRunId;
const shown = !active && shownRunIds.includes(r.id);
if (renaming === r.id) {
<div
ref={listRef}
role="tablist"
aria-label={t("tabs.label")}
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
>
{tabs.map((tab, i) => {
const active = tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const state = tabState(tab);
if (single && renaming === single.id) {
return (
<div key={r.id} className="flex items-center px-2">
<div key={tab.key} className="flex items-center px-2">
<input
data-testid={`console-tab-rename-${r.slot ?? 0}`}
data-testid={`console-tab-rename-${single.slot ?? 0}`}
aria-label={t("tree.renameSession")}
autoFocus
value={draft}
@@ -291,7 +418,7 @@ export function ConsoleTabs({
if (e.key === "Escape") setRenaming(null);
if (!isSubmitEnter(e)) return;
e.preventDefault();
void saveRename(r);
void saveRename(single);
}}
className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary"
/>
@@ -300,58 +427,79 @@ export function ConsoleTabs({
}
return (
<div
key={r.id}
key={tab.key}
data-tab-index={i}
data-slots={tab.key}
draggable
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
onContextMenu={(e) => tabCtx.open(e, r)}
data-shown={shown ? "true" : undefined}
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
onDragStart={(e) => {
if (single) startPaneDrag(e, single.slot ?? 0);
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
className={`group relative flex min-w-0 shrink-0 items-center rounded-t-md ${
single ? "max-w-[15rem]" : "max-w-[22rem]"
} ${
active
? "bg-surface-0 text-fg-primary"
: shown
? "text-fg-secondary hover:bg-surface-4"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
>
{(active || shown) && (
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
<span
className={`absolute inset-x-2 -bottom-px h-0.5 rounded-full ${
active ? "bg-accent" : "bg-fg-muted/50"
aria-hidden
data-testid="console-tab-drop-line"
className={`pointer-events-none absolute inset-y-1.5 z-10 w-0.5 rounded-full bg-accent-solid ${
dropAt === i ? "-left-px" : "-right-px"
}`}
/>
)}
{active && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
<button
type="button"
role="tab"
aria-selected={active}
tabIndex={r.id === tabStop ? 0 : -1}
tabIndex={tab.key === tabStop ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(r.id, el);
else tabRefs.current.delete(r.id);
if (el) tabRefs.current.set(tab.key, el);
else tabRefs.current.delete(tab.key);
}}
onKeyDown={(e) => onTabKey(e, i)}
data-testid={`console-tab-${r.slot ?? 0}`}
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
onDoubleClick={() => startRename(r)}
title={[
toolLabel(r),
r.model,
r.promptPreview,
t("tabs.splitHint"),
r.sessionId ? t("tabs.renameHint") : null,
]
.filter(Boolean)
.join("\n")}
data-testid={`console-tab-${tab.key}`}
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
onDoubleClick={() => single && startRename(single)}
title={
single
? [
toolLabel(single),
single.model,
single.promptPreview,
t("tabs.splitHint"),
single.sessionId ? t("tabs.renameHint") : null,
]
.filter(Boolean)
.join("\n")
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
<RunIcon run={r} label={toolLabel(r)} />
<span className="truncate">{label(r)}</span>
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />}
<span className="flex shrink-0 items-center gap-1">
{tab.runs.map((r) => (
<RunIcon key={r.id} run={r} label={toolLabel(r)} />
))}
</span>
<span className="truncate">{tabLabel(tab)}</span>
{state && <RunDot state={state} />}
</button>
<button
type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`}
onClick={() => void close(r)}
data-testid={`console-tab-close-${tab.key}`}
onClick={() => void close(tab)}
aria-label={t("tabs.close")}
title={t("tabs.close")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${
@@ -421,30 +569,36 @@ export function ConsoleTabs({
)}
</div>
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
{tabCtx.menu && (
{tabCtx.menu && ctxTab && (
<ContextMenu
x={tabCtx.menu.x}
y={tabCtx.menu.y}
onClose={tabCtx.close}
testId={`console-tab-menu-${tabCtx.menu.item.slot ?? 0}`}
testId={`console-tab-menu-${ctxTab.key}`}
>
<MenuItem onSelect={pickTab((r) => onOpen(r.id))}>{t("sessionMenu.open")}</MenuItem>
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
{t("sessionMenu.openSplit")}
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id))}>
{t("sessionMenu.open")}
</MenuItem>
{tabCtx.menu.item.sessionId && tabCtx.menu.item.tool !== "shell" && (
{ctxRun && (
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id, true))}>
{t("sessionMenu.openSplit")}
</MenuItem>
)}
{ctxRun?.sessionId && ctxRun.tool !== "shell" && (
<>
<MenuItem onSelect={pickTab(startRename)}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
<MenuItem onSelect={pickTab((tab) => startRename(tab.focus))}>
{t("menu.rename")}
</MenuItem>
<MenuItem onSelect={pickTab((tab) => navigate(`/sessions/${tab.focus.sessionId}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}>
<MenuItem onSelect={pickTab((tab) => void copyText(tab.focus.sessionId ?? ""))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem danger onSelect={pickTab((r) => void close(r))}>
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")}
</MenuItem>
</ContextMenu>
@@ -5,7 +5,7 @@
* whose button goes back to the workspace home, where a session starts with a
* prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts
* a bare CLI or a shell). No header of its own: the tab bar is the console's
* header. The Workspace page keeps one per tab (slot 0–3) mounted, the
* header. The Workspace page keeps one per live session (slot 0–9) mounted, the
* inactive ones hidden with CSS.
*
* The pane is bound to the lane it shows: switching `laneId` swaps it to the
@@ -97,7 +97,7 @@ export function LaneConsolePane({
// arrives — the request must land after that. Only this lane's own tabs.
useEffect(() => {
if (!attachRequest || laneId === null) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-9])?$`).test(attachRequest.runId)) return;
let live = true;
autoAttachedForLane.current = laneId;
api.run
+19 -14
View File
@@ -8,8 +8,10 @@
* remount it and drop what it drew. With more than one pane each gets a slim
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or HIDE the pane (its session keeps running as a tab — so
* its icon is not the tab's stop X). A header names the pane's session and
* right or down, or take the pane OUT of this tab (its session keeps running
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* right-click on a header offers the swap, a split with a new terminal and
@@ -33,8 +35,9 @@ import {
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
EMPTY_LAYOUT,
EMPTY_GROUPS,
type Edge,
type PaneGroups,
type PaneLayout,
type Rect,
type SplitDir,
@@ -42,9 +45,9 @@ import {
closeInLayout,
layoutRects,
leaves,
loadLayout,
loadGroups,
moveInLayout,
saveLayout,
saveGroups,
setRatio,
swapInLayout,
} from "../../lib/paneLayout";
@@ -72,13 +75,15 @@ export function RunIcon({ run, label }: { run: RunHandle; label: string }) {
}
export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
/** A workspace's pane layout, loaded and saved per workspace. A layout update
* that lands after a workspace switch never writes into the new one. */
export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] {
/** A workspace's tabs (groups of split panes), loaded and saved per
* workspace. An update that lands after a workspace switch never writes into
* the new one. */
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
const load = (id: number | null) => ({
laneId: id,
layout: id === null ? EMPTY_LAYOUT : loadLayout(id),
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
});
const [state, setState] = useState(() => load(laneId));
let current = state;
@@ -87,18 +92,18 @@ export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate]
setState(current);
}
useEffect(() => {
if (state.laneId !== null) saveLayout(state.laneId, state.layout);
if (state.laneId !== null) saveGroups(state.laneId, state.groups);
}, [state]);
const update = useCallback<LayoutUpdate>(
const update = useCallback<GroupsUpdate>(
(fn) =>
setState((s) => {
if (s.laneId !== laneId) return s;
const layout = fn(s.layout);
return layout === s.layout ? s : { ...s, layout };
const groups = fn(s.groups);
return groups === s.groups ? s : { ...s, groups };
}),
[laneId]
);
return [current.layout, update];
return [current.groups, update];
}
type Zone = Edge | "center";
@@ -414,7 +414,7 @@ export function AiToolsSettings() {
>
<pre
data-testid="ai-tool-confirm-command"
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
className="mt-3 whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
>
{confirmLines.join("\n")}
</pre>
@@ -17,6 +17,7 @@ import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow";
import { PathPickButton } from "../PathPickButton";
export interface HomeDirValue {
value: string;
@@ -122,6 +123,13 @@ export function HomeDirSetting({
className="input flex-1 font-mono"
placeholder={placeholder}
/>
<PathPickButton
start={input}
onPick={(p) => {
setInput(p);
setError(null);
}}
/>
<button
type="button"
onClick={handleSave}
@@ -710,8 +710,13 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
onContextMenu={(e) => ctx.open(e, rule)}
className="hover:bg-surface-4 transition-colors group"
>
<td className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern}
{/* The trailing wildcard is noise in a list of prefixes; the
full pattern stays in the tooltip, the editor and Copy. */}
<td
title={rule.model_pattern}
className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary"
>
{rule.model_pattern.replace(/%$/, "")}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-fg-secondary">
{rule.display_name}
@@ -78,7 +78,7 @@ beforeEach(() => {
describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
@@ -96,7 +96,7 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off();
@@ -115,7 +115,7 @@ describe("ClaudeAgentHubSettings", () => {
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
@@ -67,11 +67,12 @@ describe("PricingSettings", () => {
it("shows the vendor's rows, and the rest behind Show all models", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
// The trailing wildcard is hidden in the list, kept in the tooltip.
expect(await screen.findByText("gpt-5.6-sol")).toHaveAttribute("title", "gpt-5.6-sol%");
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.getByText("claude-opus-5-5")).toBeInTheDocument();
});
it("marks a row the automatic sync keeps up to date", async () => {
@@ -82,7 +83,7 @@ describe("PricingSettings", () => {
],
});
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-6-sol%");
await screen.findByText("gpt-6-sol");
expect(screen.getAllByText("Auto")).toHaveLength(1);
});
@@ -95,7 +96,7 @@ describe("PricingSettings", () => {
it("turns Show all on when a row outside the filter is added", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByRole("button", { name: /Add Model/i }));
const [pattern, name] = screen.getAllByRole("textbox");
await user.type(pattern!, "deepseek-v4%");
@@ -104,7 +105,7 @@ describe("PricingSettings", () => {
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
});
await user.click(screen.getByTitle(/save/i));
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
expect(await screen.findByText("deepseek-v4")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
});
@@ -112,8 +113,8 @@ describe("PricingSettings", () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
render(<PricingSettings vendor="claude" allowReset />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol")).toBeNull();
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /confirm/i }));
@@ -128,7 +129,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
const row = (await screen.findByText("gpt-5.6-sol")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
@@ -147,7 +148,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(false);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i);
@@ -167,14 +168,14 @@ describe("PricingSettings", () => {
<PricingSettings vendor="openai" id="b" />
</>
);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("claude-opus-5-5%");
await screen.findByText("gpt-5.6-sol");
await screen.findByText("claude-opus-5-5");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
});
// The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
expect(await screen.findByText("claude-fable-5")).toBeInTheDocument();
confirm.off();
});
});
@@ -94,7 +94,7 @@ export function LanePipelinePanel({
t("lanes.emptyLane")
) : (
<>
{t("lanes.empty")} <code>agenthub lanes add --cwd $(pwd)</code>
{t("lanes.empty")} <code>agenthub workspace add --cwd $(pwd)</code>
</>
)}
</p>
@@ -151,7 +151,7 @@ describe("LanePipelinePanel", () => {
statsMock.mockResolvedValue({ pipelines: [], lanes: [] });
render(<LanePipelinePanel />);
expect(await screen.findByText(/agenthub lanes add/)).toBeInTheDocument();
expect(await screen.findByText(/agenthub workspace add/)).toBeInTheDocument();
});
it("refetches narrowed to the lane picked, and falls back to a cwd for an untitled one", async () => {
@@ -181,7 +181,7 @@ describe("LanePipelinePanel", () => {
// the whole subtree, so a handle grabbed before it is already detached.
await waitFor(() => expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument());
expect(screen.getByTestId("pipeline-stats-lane-picker")).toBeInTheDocument();
expect(screen.queryByText(/agenthub lanes add/)).not.toBeInTheDocument();
expect(screen.queryByText(/agenthub workspace add/)).not.toBeInTheDocument();
});
it("surfaces a failed fetch", async () => {
+10 -10
View File
@@ -45,8 +45,10 @@
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
"browse.button": "Browse",
"browse.empty": "This folder is empty.",
"browse.fileTitle": "Browse for a file",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"browse.typePath": "No native picker is available here — type the path instead.",
"changes.back": "All files",
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
@@ -241,6 +243,7 @@
"changes.nextHunk": "Next change",
"changes.hunkOf": "{{n}} of {{total}}",
"changes.noEditBinary": "Binary file — not editable here",
"changes.imageFailed": "Could not show this image (deleted, or larger than 20 MB).",
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
"changes.noEdit": "Not editable here",
"gitBar.loginTitle": "Git needs a login for {{host}}",
@@ -291,8 +294,6 @@
"changes.takeTheirsHint": "Take the incoming version of the whole file",
"row.actions": "Workspace actions",
"row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions",
"menu.start": "Start",
"menu.stop": "Stop",
"menu.rename": "Rename",
"menu.newHere": "New workspace here…",
"menu.copyPath": "Copy path",
@@ -321,6 +322,7 @@
"tree.allTools": "All tools",
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
"tree.renameSession": "Rename session",
@@ -349,7 +351,7 @@
"home.localHint": "Work in the workspace folder itself",
"home.worktree": "New worktree",
"home.worktreeHint": "Run this session in its own git worktree — <repo>/.claude/worktrees/, a branch of its own, made and cleaned up by Claude Code",
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
"home.allBusy": "All 10 sessions of this workspace are in use — stop one before starting another.",
"home.recents": "Recents",
"home.noRecents": "No sessions yet — start one above.",
"home.instructions": "Instructions",
@@ -380,7 +382,7 @@
"tabs.newTerminal": "New terminal",
"tabs.terminal": "Terminal",
"tabs.close": "Close tab (stops its session)",
"tabs.full": "All 4 tabs are in use — close one first",
"tabs.full": "All 10 sessions are in use — stop one first",
"tabs.agentSettings": "Agent settings…",
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
@@ -388,9 +390,9 @@
"panes.empty": "No session",
"panes.split.right": "Split right",
"panes.split.bottom": "Split down",
"panes.full": "4 panes is the most a workspace can show",
"panes.full": "A tab holds at most 4 panes",
"panes.newTerminal": "New terminal",
"panes.close": "Hide pane (its session keeps running as a tab)",
"panes.close": "Move to its own tab (the session keeps running)",
"panes.resize": "Resize panes",
"tabs.home": "Workspace home",
"sessionMenu.open": "Open",
@@ -401,7 +403,7 @@
"panes.show": "Show {{name}} here",
"panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal",
"panes.closeShort": "Hide pane",
"panes.closeShort": "Move to own tab",
"changes.copyPath": "Copy relative path",
"changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA",
@@ -414,6 +416,7 @@
"tabs.state.running": "Running",
"tabs.renameHint": "Double-click to rename",
"tabs.stopTitle": "Stop “{{name}}”?",
"tabs.stopGroupTitle": "Stop the {{count}} sessions in this tab?",
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
"tabs.stopConfirm": "Stop session",
"changes.loadMore": "Load more commits",
@@ -434,9 +437,6 @@
"row.state.running": "running",
"row.state.active": "active",
"row.state.idle": "idle",
"menu.stopTitle_one": "Stop the running session?",
"menu.stopTitle_other": "Stop {{count}} running sessions?",
"menu.stopBody": "Every tab of this workspace stops, even mid-turn. Its sessions can be resumed later from Recents.",
"gitBar.done.fetch": "Fetched",
"gitBar.done.pull": "Pulled",
"gitBar.done.push": "Pushed",
+2 -1
View File
@@ -66,7 +66,8 @@
"needYou": "{{count}} need you",
"today": "Today {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
"session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)"
},
"version": "Version",
"openMenu": "Open menu",
+10 -10
View File
@@ -45,8 +45,10 @@
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
"browse.button": "Duyệt",
"browse.empty": "Thư mục này trống.",
"browse.fileTitle": "Duyệt chọn tệp",
"browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục",
"browse.typePath": "Không mở được hộp thoại chọn của hệ điều hành ở đây — hãy gõ đường dẫn.",
"changes.back": "Tất cả file",
"changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
@@ -241,6 +243,7 @@
"changes.nextHunk": "Thay đổi sau",
"changes.hunkOf": "{{n}} / {{total}}",
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
"changes.imageFailed": "Không hiển thị được ảnh này (đã bị xoá, hoặc lớn hơn 20 MB).",
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
"changes.noEdit": "Không sửa được ở đây",
"gitBar.loginTitle": "Git cần đăng nhập {{host}}",
@@ -291,8 +294,6 @@
"changes.takeTheirsHint": "Lấy bản kia cho cả file",
"row.actions": "Thao tác workspace",
"row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác",
"menu.start": "Bắt đầu",
"menu.stop": "Dừng",
"menu.rename": "Đổi tên",
"menu.newHere": "Workspace mới tại đây…",
"menu.copyPath": "Sao chép đường dẫn",
@@ -321,6 +322,7 @@
"tree.allTools": "Mọi tool",
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
"tree.renameSession": "Đổi tên session",
@@ -349,7 +351,7 @@
"home.localHint": "Làm việc ngay trong thư mục của workspace",
"home.worktree": "Worktree mới",
"home.worktreeHint": "Chạy session này trong worktree git riêng — <repo>/.claude/worktrees/, branch riêng, do Claude Code tạo và dọn",
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
"home.allBusy": "Workspace này đã dùng hết 10 session — hãy dừng một session trước khi mở session mới.",
"home.recents": "Gần đây",
"home.noRecents": "Chưa có session nào — bắt đầu ở ô phía trên.",
"home.instructions": "Hướng dẫn",
@@ -380,7 +382,7 @@
"tabs.newTerminal": "Terminal mới",
"tabs.terminal": "Terminal",
"tabs.close": "Đóng tab (dừng phiên của nó)",
"tabs.full": "Đã dùng hết 4 tab — hãy đóng bớt một tab",
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
"tabs.agentSettings": "Cài đặt agent…",
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
@@ -388,9 +390,9 @@
"panes.empty": "Không có session",
"panes.split.right": "Tách sang phải",
"panes.split.bottom": "Tách xuống dưới",
"panes.full": "Một workspace hiện tối đa 4 pane",
"panes.full": "Một tab chứa tối đa 4 pane",
"panes.newTerminal": "Terminal mới",
"panes.close": "Ẩn pane (session vẫn chạy dưới dạng tab)",
"panes.close": "Tách ra tab riêng (session vẫn chạy)",
"panes.resize": "Đổi kích thước pane",
"tabs.home": "Trang chủ workspace",
"sessionMenu.open": "Mở",
@@ -401,7 +403,7 @@
"panes.show": "Hiện {{name}} ở đây",
"panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
"panes.closeShort": "Ẩn pane",
"panes.closeShort": "Tách ra tab riêng",
"changes.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của commit",
@@ -414,6 +416,7 @@
"tabs.state.running": "Đang chạy",
"tabs.renameHint": "Nhấp đúp để đổi tên",
"tabs.stopTitle": "Dừng “{{name}}”?",
"tabs.stopGroupTitle": "Dừng {{count}} session trong tab này?",
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
"tabs.stopConfirm": "Dừng phiên",
"changes.loadMore": "Tải thêm commit",
@@ -434,9 +437,6 @@
"row.state.running": "đang chạy",
"row.state.active": "đang hoạt động",
"row.state.idle": "nghỉ",
"menu.stopTitle_one": "Dừng phiên đang chạy?",
"menu.stopTitle_other": "Dừng {{count}} phiên đang chạy?",
"menu.stopBody": "Mọi tab của workspace này sẽ dừng, kể cả khi đang chạy dở. Có thể tiếp tục các phiên sau từ mục Gần đây.",
"gitBar.done.fetch": "Đã fetch",
"gitBar.done.pull": "Đã pull",
"gitBar.done.push": "Đã push",
+2 -1
View File
@@ -66,7 +66,8 @@
"needYou": "{{count}} cần bạn",
"today": "Hôm nay {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
"session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)"
},
"version": "Phiên bản",
"openMenu": "Mở menu",
@@ -2,12 +2,20 @@
* @file laneOrderStorage.test.ts
* @description Tests for the Workspace lane strip's stored card order: the
* storage round-trip, and the two rules that keep a stale stored order from
* losing a lane — unknown ids land at the end, vanished ids are skipped.
* losing a lane — unknown ids land at the end, vanished ids are skipped — plus
* the drop math: a drop lands between items by the pointer's position.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
import {
readLaneOrder,
writeLaneOrder,
sortByStoredOrder,
moveLane,
insertionIndex,
moveBefore,
} from "../laneOrderStorage";
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
@@ -72,4 +80,21 @@ describe("moveLane", () => {
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
});
it("places a drop before the first item whose middle is past the pointer", () => {
const mids = [10, 30, 50];
expect(insertionIndex(mids, 0)).toBe(0);
expect(insertionIndex(mids, 20)).toBe(1); // a gap between two items
expect(insertionIndex(mids, 49)).toBe(2);
expect(insertionIndex(mids, 90)).toBe(3); // past the last: the end
});
it("moves an item to just before an insertion point", () => {
expect(moveBefore([1, 2, 3, 4], 0, 2)).toEqual([2, 1, 3, 4]);
expect(moveBefore([1, 2, 3, 4], 0, 4)).toEqual([2, 3, 4, 1]);
expect(moveBefore([1, 2, 3, 4], 3, 0)).toEqual([4, 1, 2, 3]);
// Before itself or right after itself: no move.
expect(moveBefore([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
expect(moveBefore([1, 2, 3], 1, 2)).toEqual([1, 2, 3]);
});
});
+124 -11
View File
@@ -3,26 +3,36 @@
* @description Tests for the session view's split-pane tree: opening a run
* (replace the focused pane, or split it), closing a pane and handing focus to
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, and the
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
type PaneGroups,
type PaneLayout,
type PaneNode,
EMPTY_GROUPS,
MAX_PANES,
closeInLayout,
detachSlot,
layoutRects,
leaves,
loadLayout,
loadGroups,
moveInLayout,
openInGroups,
openInLayout,
pane,
parseGroups,
parseLayout,
pruneGroups,
pruneLayout,
saveLayout,
removeSlot,
reorderGroups,
saveGroups,
setRatio,
updateActive,
split,
swapInLayout,
} from "../paneLayout";
@@ -182,9 +192,112 @@ describe("pruneLayout", () => {
});
});
describe("pane groups (tabs of up to four panes)", () => {
const single = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
const g2: PaneGroups = { groups: [single(0), single(1)], active: 0 };
it("opens a new session as a tab of its own after the active one", () => {
const out = openInGroups(g2, 5, false);
expect(out.groups.map((g) => leaves(g.root))).toEqual([[0], [5], [1]]);
expect(out.active).toBe(1);
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
});
it("a split open joins the active tab, and the tab it left goes", () => {
const out = openInGroups(g2, 1, true);
expect(out.groups).toHaveLength(1);
expect(leaves(out.groups[0]!.root)).toEqual([0, 1]);
expect(out.groups[0]!.focus).toBe(1);
});
it("a split open on a full tab opens its own tab instead", () => {
let g: PaneGroups = { groups: [single(0)], active: 0 };
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
const out = openInGroups(g, 4, true);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]);
expect(out.active).toBe(1);
});
it("a pane leaving the active tab becomes a tab of its own next to it", () => {
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 0,
};
const out = updateActive(g, (l) => closeInLayout(l, 1));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [1], [2]]);
expect(out.active).toBe(0);
});
it("a pane moved in from another tab leaves that tab", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 0 };
const out = updateActive(g, (l) => moveInLayout(l, 2, 0, "bottom"));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 2], [1]]);
expect(out.active).toBe(0);
});
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
expect(pruneGroups(g, new Set([0, 1, 2]), new Set())).toBe(g);
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
const moved = reorderGroups(out, 2, 0);
expect(moved.groups.map((x) => leaves(x.root))).toEqual([[2], [1], [0]]);
expect(moved.active).toBe(1);
});
});
describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
it("keeps the stored active tab selected when a tab before it is dropped", () => {
const b = { root: pane(2), focus: 2 };
const c = { root: pane(3), focus: 3 };
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }, b, c], active: 1 })).toEqual({
groups: [b, c],
active: 0,
});
expect(parseGroups({ groups: [b, c], active: 7 }).active).toBe(1);
});
it("accepts a valid stored layout", () => {
const l: PaneLayout = { root: split("col", pane(1), pane(3), 0.3), focus: 3 };
expect(parseLayout(JSON.parse(JSON.stringify(l)))).toEqual(l);
@@ -192,7 +305,7 @@ describe("parseLayout / storage", () => {
it("rejects junk, out-of-range slots, duplicates and oversized trees", () => {
expect(parseLayout(null)).toEqual({ root: null, focus: null });
expect(parseLayout({ root: { type: "pane", slot: 7 }, focus: 7 }).root).toBeNull();
expect(parseLayout({ root: { type: "pane", slot: 10 }, focus: 10 }).root).toBeNull();
expect(parseLayout({ root: split("row", pane(1), pane(1)), focus: 1 }).root).toBeNull();
const five = split(
"row",
@@ -208,16 +321,16 @@ describe("parseLayout / storage", () => {
expect(out.focus).toBe(0);
});
it("keeps one layout per workspace", () => {
saveLayout(1, { root: split("row", pane(0), pane(1)), focus: 1 });
saveLayout(2, one);
expect(loadLayout(1).focus).toBe(1);
expect(loadLayout(2)).toEqual(one);
expect(loadLayout(3)).toEqual({ root: null, focus: null });
it("keeps one set of tabs per workspace", () => {
saveGroups(1, { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
saveGroups(2, { groups: [one], active: 0 });
expect(loadGroups(1).groups[0]?.focus).toBe(1);
expect(loadGroups(2)).toEqual({ groups: [one], active: 0 });
expect(loadGroups(3)).toEqual(EMPTY_GROUPS);
});
it("survives unreadable storage", () => {
window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json");
expect(loadLayout(1)).toEqual({ root: null, focus: null });
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
+39 -1
View File
@@ -1380,13 +1380,15 @@ export const api = {
*
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/
totalCost: (opts: { lane?: number } = {}) => {
totalCost: (opts: { lane?: number; date?: string } = {}) => {
// Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total.
// `lane`: only the sessions that workspace owns.
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date);
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
@@ -2176,6 +2178,14 @@ export const api = {
run: {
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
list: () => request<RunListResponse>("/run"),
/** GET /api/run/history - persisted runs, newest first; `laneId` narrows to
* one workspace and `tool` (with it) to one tool's — `shell` = Terminal tabs. */
history: (opts: { laneId: number; tool?: string; limit?: number }) => {
const qs = new URLSearchParams({ laneId: String(opts.laneId) });
if (opts.tool) qs.set("tool", opts.tool);
if (opts.limit) qs.set("limit", String(opts.limit));
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
@@ -2607,6 +2617,20 @@ export const api = {
parent: string | null;
entries: { name: string; path: string; isGitRepo: boolean }[];
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
/**
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
* browser on the server's machine), `EBADORIGIN` or `ENODIALOG` (no desktop
* / no picker program) when the caller should fall back to {@link browse}.
* @param kind `dir` or `file`.
* @param start Folder (or file) to open the picker at; home otherwise.
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
*/
pickPath: (kind: "dir" | "file", start?: string) =>
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
method: "POST",
body: JSON.stringify({ kind, start }),
}),
/**
* GET /api/lanes/:id — fetch one lane.
* @param id The lane id.
@@ -2666,6 +2690,9 @@ export const api = {
*/
gitFile: (id: GitTarget, path: string, rev = "work") =>
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
@@ -4011,6 +4038,17 @@ export interface RunListResponse {
items: RunHandle[];
}
/** One row of GET /api/run/history (`dashboard_runs`; only the fields read). */
export interface RunHistoryItem {
id: string;
tool: string;
status: string;
started_at: string;
ended_at: string | null;
/** The run's tmux pane is still up. */
isLive: boolean;
}
/** One suggested working directory for the Run page's cwd picker. */
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
+21 -2
View File
@@ -1,7 +1,9 @@
/**
* @file laneOrderStorage.ts
* @description Persists the Workspace lane strip's user-chosen card order to
* localStorage, the same read/validate/fallback convention as useTheme.ts.
* @description Persists the sidebar's user-chosen workspace order to
* localStorage, the same read/validate/fallback convention as useTheme.ts, and
* holds the drag-to-reorder math the sidebar and the console tab bar share
* (the tab bar's own order lives with its tabs, `lib/paneLayout.ts`).
*
* The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, and nothing
@@ -66,3 +68,20 @@ export function moveLane(ids: number[], from: number, to: number): number[] {
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
/** Where a drop lands: before the first item whose middle (`mids`, on the
* drag axis) lies past the pointer, else at the end. Gaps between items —
* a workspace's session list, the space between two tabs — count too. */
export function insertionIndex(mids: number[], pos: number): number {
const i = mids.findIndex((m) => pos < m);
return i === -1 ? mids.length : i;
}
/** Moves the item at `from` to just before insertion point `before`. */
export function moveBefore<T>(ids: T[], from: number, before: number): T[] {
if (from < 0 || from >= ids.length || before < 0 || before > ids.length) return ids;
if (before === from || before === from + 1) return ids;
const next = [...ids];
next.splice(before > from ? before - 1 : before, 0, ...next.splice(from, 1));
return next;
}
+163 -10
View File
@@ -1,20 +1,29 @@
/**
* @file paneLayout.ts
* @description The session view's split panes, VS Code style: a binary tree
* whose leaves are pane SLOTS (0–3, one tmux session each — see
* whose leaves are pane SLOTS (0–9, one tmux session each — see
* `server/lib/pty-run.js`) and whose inner nodes split their box in a row
* (side by side) or a column (stacked) at a ratio. Pure functions only: the
* page keeps the layout in state, and `layoutRects` turns it into the
* rectangles `PaneGrid` draws every pane at — each pane is an absolutely
* placed sibling, so reshaping the tree never re-parents (and so never
* remounts) a live terminal. A slot appears at most once: one terminal is
* never attached twice. The layout is a viewer preference, kept per workspace
* in `localStorage` (`agenthub.workspace.paneLayout`), never on the server.
* never attached twice.
*
* The session view's TABS are groups of such trees (`PaneGroups`): each tab
* holds one to `MAX_PANES` panes, every live session sits in exactly one tab,
* and a session on its own is a one-pane tab — splitting or dropping one tab
* onto another's pane merges them into one tab, closing a pane of a tab puts
* its session back in a tab of its own. The tabs are a viewer preference,
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
* stored pre-tab single layout reads as one tab), never on the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** As many panes as a workspace has tmux slots. */
/** Panes in one tab. */
export const MAX_PANES = 4;
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
export const MAX_SLOTS = 10;
const MIN_RATIO = 0.1;
const STORAGE_KEY = "agenthub.workspace.paneLayout";
@@ -202,7 +211,7 @@ function parseNode(raw: unknown): PaneNode | null {
if (!raw || typeof raw !== "object") return null;
const n = raw as Record<string, unknown>;
if (n.type === "pane") {
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_PANES
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_SLOTS
? pane(n.slot as number)
: null;
}
@@ -225,6 +234,150 @@ export function parseLayout(raw: unknown): PaneLayout {
return { root, focus: focusOf({ root, focus: typeof focus === "number" ? focus : null }) };
}
// ── Tabs: groups of up to MAX_PANES panes ─────────────────────────────
/** The tabs, each a split tree, and the one on screen. */
export interface PaneGroups {
groups: PaneLayout[];
active: number;
}
export const EMPTY_GROUPS: PaneGroups = { groups: [], active: 0 };
const solo = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
/** The tab on screen (an empty layout when there is none). */
export const activeLayout = (g: PaneGroups): PaneLayout => g.groups[g.active] ?? EMPTY;
/** The tab holding `slot`, or -1. */
export const groupOf = (g: PaneGroups, slot: number): number =>
g.groups.findIndex((l) => leaves(l.root).includes(slot));
/** Drops emptied tabs. The active tab stays active; when it went, the tab
* before it takes over (the first one when it was first). */
function compact(groups: PaneLayout[], active: number): PaneGroups {
const kept = groups.filter((l) => l.root);
if (!kept.length) return EMPTY_GROUPS;
if (groups[active]?.root) return { groups: kept, active: kept.indexOf(groups[active]) };
const before = groups.slice(0, active).filter((l) => l.root).length;
return { groups: kept, active: Math.max(0, Math.min(before - 1, kept.length - 1)) };
}
/**
* Changes the tab on screen with a per-tree operation (`moveInLayout`,
* `swapInLayout`, `closeInLayout`, `setRatio`…). A session the change brings
* in leaves the tab it was in; one it takes out gets a tab of its own next to
* this one — closing a pane never hides a running session.
*/
export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout): PaneGroups {
const base = g.groups.length ? g : { groups: [EMPTY], active: 0 };
const cur = base.groups[base.active] ?? EMPTY;
const next = fn(cur);
if (next === cur) return g;
const before = leaves(cur.root);
const after = leaves(next.root);
const added = after.filter((s) => !before.includes(s));
const solos = before.filter((s) => !after.includes(s)).map(solo);
const groups = base.groups.map((l, i) =>
i === base.active ? next : added.reduce((acc, s) => closeInLayout(acc, s), l)
);
// An emptied tab is replaced by what left it, in its place.
if (next.root) groups.splice(base.active + 1, 0, ...solos);
else groups.splice(base.active, 1, ...solos);
return compact(groups, base.active);
}
/**
* Shows `slot`. Its own tab is activated (and the slot focused there); a
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
* click) brings it into the active tab beside the focused pane instead —
* unless that tab is already full, when it falls back to the plain open.
*/
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
const cur = activeLayout(g);
if (split && cur.root && (leaves(cur.root).includes(slot) || leaves(cur.root).length < MAX_PANES))
return updateActive(g, (l) => openInLayout(l, slot, "split"));
const at = groupOf(g, slot);
if (at >= 0) {
const groups = [...g.groups];
groups[at] = { ...(groups[at] as PaneLayout), focus: slot };
return { groups, active: at };
}
const i = g.groups.length ? g.active + 1 : 0;
const groups = [...g.groups];
groups.splice(i, 0, solo(slot));
return { groups, active: i };
}
/** A session ended: its pane goes, and its tab when that was the last one. */
export function removeSlot(g: PaneGroups, slot: number): PaneGroups {
const at = groupOf(g, slot);
if (at < 0) return g;
const groups = [...g.groups];
groups[at] = closeInLayout(groups[at] as PaneLayout, slot);
return compact(groups, g.active);
}
/** Takes `slot` out of its tab into a tab of its own, inserted before the tab
* now at `at` (`groups.length` = last), and shows it. */
export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups {
const from = groupOf(g, slot);
if (from < 0) return g;
const groups = g.groups.map((l, i) => (i === from ? closeInLayout(l, slot) : l));
groups.splice(at, 0, solo(slot));
return compact(groups, at);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;
const groups = [...g.groups];
const [moved] = groups.splice(from, 1);
groups.splice(at > from ? at - 1 : at, 0, moved as PaneLayout);
return { groups, active: groups.indexOf(g.groups[g.active] as PaneLayout) };
}
/**
* Drops every pane whose session is neither `live` nor `keep` (see
* `pruneLayout`) and the tabs that leaves empty, and gives every live session
* no tab holds one of its own. Same object back when nothing changed.
*/
export function pruneGroups(g: PaneGroups, live: Set<number>, keep: Set<number>): PaneGroups {
let changed = false;
const groups = g.groups.map((l) => {
const n = pruneLayout(l, live, keep);
if (n !== l) changed = true;
return n;
});
const tabbed = new Set(groups.flatMap((l) => leaves(l.root)));
const missing = [...live].filter((s) => !tabbed.has(s)).sort((a, b) => a - b);
if (!changed && !missing.length) return g;
return compact([...groups, ...missing.map(solo)], g.active);
}
/** Stored tabs, checked: a malformed tab, or one showing a session an
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
export function parseGroups(raw: unknown): PaneGroups {
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
const r = raw as { groups?: unknown; active?: unknown };
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
const rawActive = Number.isInteger(r.active) ? (r.active as number) : 0;
const seen = new Set<number>();
const groups: PaneLayout[] = [];
// `active` indexes the STORED list; count only the tabs kept before it, so
// a dropped tab ahead of it does not shift the selection onto its neighbour.
let active = 0;
list.forEach((item, i) => {
const l = parseLayout(item);
const shown = leaves(l.root);
if (!l.root || shown.some((s) => seen.has(s))) return;
shown.forEach((s) => seen.add(s));
if (i < rawActive) active++;
groups.push(l);
});
if (!groups.length) return EMPTY_GROUPS;
return { groups, active: Math.min(active, groups.length - 1) };
}
function readAll(): Record<string, unknown> {
try {
const all = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "{}");
@@ -234,17 +387,17 @@ function readAll(): Record<string, unknown> {
}
}
export function loadLayout(laneId: number): PaneLayout {
return parseLayout(readAll()[String(laneId)]);
export function loadGroups(laneId: number): PaneGroups {
return parseGroups(readAll()[String(laneId)]);
}
export function saveLayout(laneId: number, layout: PaneLayout): void {
export function saveGroups(laneId: number, g: PaneGroups): void {
const all = readAll();
if (layout.root) all[String(laneId)] = layout;
if (g.groups.length) all[String(laneId)] = g;
else delete all[String(laneId)];
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* disabled storage: the layout lives for this page only */
/* disabled storage: the tabs live for this page only */
}
}
+8 -6
View File
@@ -2870,6 +2870,14 @@ export function Dashboard() {
</div>
</div>
<div className="flex items-center gap-3">
{/* Monitor's Refresh sits LEFT of the tabs: on their right it pushed
Analytics/Monitor/Health aside every time Monitor opened.
Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
)}
{/* Tabs */}
<div
role="tablist"
@@ -2892,12 +2900,6 @@ export function Dashboard() {
</button>
))}
</div>
{/* Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
)}
</div>
</div>
+87 -68
View File
@@ -5,7 +5,8 @@
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
* Shows the selected lane's pipeline map (when a template is installed) and run console (a
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
* tab per group of up to four split panes over the live tmux slots 0–9, with a
* `+` menu — `ConsoleTabs.tsx`), and the
* right panel —
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
@@ -84,18 +85,23 @@ import {
type LayoutUpdate,
PaneGrid,
SplitMenus,
usePaneLayout,
usePaneGroups,
} from "../components/run/PaneGrid";
import {
type Edge,
type PaneGroups,
type PaneLayout,
MAX_PANES,
activeLayout,
closeInLayout,
groupOf,
leaves,
moveInLayout,
openInLayout,
openInGroups,
pane,
pruneLayout,
pruneGroups,
removeSlot,
updateActive,
} from "../lib/paneLayout";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
@@ -105,6 +111,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
@@ -161,20 +168,20 @@ const CHANGES_MIN_PX = 288;
/** The pane slot a run id names (`agenthub-lane-<id>[-<slot>]`). */
function slotOf(runId: string | null | undefined): number {
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-3]))?$/);
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-9]))?$/);
return m ? Number(m[1] || 0) : 0;
}
/** The session view's console: a tab bar over the workspace's split panes
* (`PaneGrid`; layout model in `lib/paneLayout.ts`), one `LaneConsolePane`
* per slot 0–3, each its own tmux session on the selected lane. The focused
* pane is the URL's `?run=`: a plain open (a tab, a sidebar row) shows its run
* in the focused pane — or focuses the pane already showing it — and a split
* open (Ctrl/Cmd+click) opens it to the right of the focused one. Every tab
* with a live run stays MOUNTED, hidden with CSS while no pane shows it, so
* neither switching tabs nor reshaping the panes ever drops a terminal. A
* pane whose run ends closes; the last one leaves for another tab, else the
* home. */
/** The session view's console: a tab bar whose every tab is a group of up to
* four split panes (`PaneGrid`; model in `lib/paneLayout.ts`), one
* `LaneConsolePane` per slot 0–9, each its own tmux session on the selected
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
* open (a tab, a sidebar row) shows the tab holding that run — a session in
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
* into the tab on screen, to the right of the focused pane. Every live run
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
* run ends closes; the last tab gone leaves for the home. */
function ConsoleArea({
lanes,
selectedLaneId,
@@ -216,11 +223,12 @@ function ConsoleArea({
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
[activeRuns, selectedLaneId]
);
const [layout, setLayout] = usePaneLayout(selectedLaneId);
// The latest layout, updated the moment a change is made: two changes in
// one commit (an open request and a prune) must build on each other.
const layoutRef = useRef(layout);
layoutRef.current = layout;
const [groups, setGroups] = usePaneGroups(selectedLaneId);
// The latest tabs, updated the moment a change is made: two changes in one
// commit (an open request and a prune) must build on each other.
const groupsRef = useRef(groups);
groupsRef.current = groups;
const layout = activeLayout(groups);
// Slots opened here whose run the polled list has not caught up with yet,
// each with the list it was opened against: pruning spares one until a
// NEWER list still lacks it (a run that died before it was ever listed).
@@ -263,16 +271,16 @@ function ConsoleArea({
// is no effect watching the focus — an open request and that effect would
// keep overriding each other. Session view only: the home and a replay
// keep their URL.
const followFocus = (next: PaneLayout) => {
const followFocus = (next: PaneGroups) => {
if (!attachRequest) return;
const run = runOf(next.focus);
const run = runOf(activeLayout(next).focus);
if (run && run.id !== attachRequest.runId) onOpenRun(run.id, { focusOnly: true });
};
const change: LayoutUpdate = (fn) => {
const next = fn(layoutRef.current);
if (next === layoutRef.current) return;
layoutRef.current = next;
setLayout(() => next);
const changeGroups = (fn: (g: PaneGroups) => PaneGroups) => {
const next = fn(groupsRef.current);
if (next === groupsRef.current) return;
groupsRef.current = next;
setGroups(() => next);
followFocus(next);
};
@@ -286,19 +294,23 @@ function ConsoleArea({
// lane arrives on a cold load; only this lane's own tabs.
useEffect(() => {
if (!attachRequest || selectedLaneId === null) return;
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-3])?$`).test(attachRequest.runId)) return;
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return;
const slot = slotOf(attachRequest.runId);
// The URL catching up with a focus change: by now that pane may be closed
// or swapped, and re-opening it would undo what the user just did. Back/
// Forward onto such an entry puts the real focus back in the URL instead.
if (attachRequest.focusOnly) {
if (leaves(layoutRef.current.root).includes(slot))
change((l) => (l.focus === slot ? l : { ...l, focus: slot }));
else followFocus(layoutRef.current);
const g = groupsRef.current;
const at = groupOf(g, slot);
if (at >= 0)
changeGroups(() =>
at === g.active && g.groups[at]?.focus === slot ? g : openInGroups(g, slot, false)
);
else followFocus(g);
return;
}
if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns);
change((l) => openInLayout(l, slot, attachRequest.split ? "split" : "replace"));
changeGroups((g) => openInGroups(g, slot, attachRequest.split === true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce, selectedLaneId]);
@@ -318,12 +330,14 @@ function ConsoleArea({
}
const first = listed.current !== selectedLaneId;
listed.current = selectedLaneId;
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
const keep = first
? new Set(pending.current.keys())
: new Set(leaves(layoutRef.current.root).filter((s) => !expired.has(s)));
const before = layoutRef.current;
change((l) => pruneLayout(l, live, keep));
if (layoutRef.current === before && before.focus !== null && arrived.includes(before.focus))
: new Set(tabbed.filter((s) => !expired.has(s)));
const before = groupsRef.current;
changeGroups((g) => pruneGroups(g, live, keep));
const focus = activeLayout(before).focus;
if (groupsRef.current === before && focus !== null && arrived.includes(focus))
followFocus(before);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRuns, tabs, selectedLaneId]);
@@ -335,9 +349,9 @@ function ConsoleArea({
onRunsChanged();
const dead = runOf(slot);
if (dead) exited.current.add(runKey(dead));
if (!leaves(layoutRef.current.root).includes(slot)) return;
change((l) => closeInLayout(l, slot));
if (layoutRef.current.root || !attachRequest) return;
if (groupOf(groupsRef.current, slot) < 0) return;
changeGroups((g) => removeSlot(g, slot));
if (groupsRef.current.groups.length || !attachRequest) return;
const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r)));
if (other) onOpenRun(other.id);
else onNewSession?.();
@@ -358,7 +372,7 @@ function ConsoleArea({
onRunsChanged();
if (laneRef.current !== laneId) return;
pending.current.set(slot, activeRuns);
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
change((l) => moveInLayout(l, slot, target, edge));
} catch (err) {
toast.error(err);
}
@@ -369,12 +383,17 @@ function ConsoleArea({
// all): one pane, so the console always has its single render site.
const fallback = requested[0] ?? 0;
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
// saved are the one-pane `view`: change that.
const change: LayoutUpdate = (fn) =>
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
const shown = leaves(view.root);
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
const focusSlot = view.focus;
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
// terminal, Alt+W hides it. Session view only, and never from inside a
// terminal, Alt+W takes it out into a tab of its own. Session view only, and never from inside a
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
// terminal first). Read through a ref: one listener, always current.
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
@@ -414,8 +433,8 @@ function ConsoleArea({
<ConsoleTabs
lane={lane}
tabs={tabs}
activeRunId={runOf(focusSlot)?.id ?? attachRequest?.runId ?? null}
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
groups={viewGroups}
onGroupsChange={changeGroups}
onOpen={(runId, split) => {
onRunsChanged();
// A tab click is not a new history entry: Back leaves the
@@ -435,8 +454,7 @@ function ConsoleArea({
shown={shown}
runs={tabs}
label={label}
// A layout never saved is the one-pane `view`: split that.
onChange={(fn) => change((l) => fn(l.root ? l : view))}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
@@ -963,7 +981,7 @@ export function Workspace() {
<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)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
@@ -992,17 +1010,12 @@ export function Workspace() {
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onMenuAction={(action, runId) => {
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
} else if (action === "started") {
setSearchParams({
lane: String(currentLane.id),
run: runId ?? `agenthub-lane-${currentLane.id}`,
});
}
}}
/>
@@ -1323,21 +1336,27 @@ export function Workspace() {
</div>
</aside>
) : (
<button
type="button"
data-testid="lane-changes-expand"
onClick={() => setChangesOpen(true)}
aria-expanded={false}
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.expand")}
title={tLanes("changes.expand")}
className="flex w-9 shrink-0 flex-col items-center gap-2 border-l border-border bg-surface-1 py-3 text-fg-muted transition-colors hover:text-fg-primary"
>
<PanelLeftClose className="h-4 w-4" />
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
{tLanes("changes.title")}
</span>
</button>
// The rail also carries the uncommitted counts, vertical, with
// the per-file list on hover — a sibling of the expand button,
// since a button may not hold another.
<div className="flex w-9 shrink-0 flex-col items-center gap-3 border-l border-border bg-surface-1 py-3">
<button
type="button"
data-testid="lane-changes-expand"
onClick={() => setChangesOpen(true)}
aria-expanded={false}
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.expand")}
title={tLanes("changes.expand")}
className="flex flex-col items-center gap-2 text-fg-muted transition-colors hover:text-fg-primary"
>
<PanelLeftClose className="h-4 w-4" />
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
{tLanes("changes.title")}
</span>
</button>
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
</div>
))}
</div>
</div>
+46 -27
View File
@@ -7,7 +7,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, within } from "@testing-library/react";
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
@@ -56,6 +56,8 @@ let nextRunId = 1;
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
}));
// Hoisted with the mock: a workspace holds ten sessions (MAX_PANE_SLOT 9).
const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
@@ -63,7 +65,7 @@ vi.mock("../../components/lanes/startSession", () => ({
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
return SLOTS.find((s) => !busy.has(s)) ?? null;
},
// The server picks the slot (`slot: "auto"`); stand in for it with the first
// one the mocked run list leaves free.
@@ -75,7 +77,7 @@ vi.mock("../../components/lanes/startSession", () => ({
.filter((r) => r.laneId === laneId && r.status === "running")
.map((r) => r.slot ?? 0)
);
const slot = [0, 1, 2, 3].find((x) => !busy.has(x)) ?? 0;
const slot = SLOTS.find((x) => !busy.has(x)) ?? 0;
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
},
@@ -621,9 +623,13 @@ describe("console tabs", () => {
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("disables + once all four slots are taken", async () => {
it("keeps + enabled past four sessions and disables it once all ten slots are taken", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeEnabled();
cleanup();
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
@@ -685,7 +691,7 @@ describe("split panes", () => {
const stored = () =>
JSON.parse(window.localStorage.getItem("agenthub.workspace.paneLayout") ?? "{}")["1"];
it("Ctrl+click on a tab opens it in a split beside the focused pane, and remembers it", async () => {
it("Ctrl+click on a tab brings it into the tab on screen as a split, one combo tab", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-0"]);
@@ -696,29 +702,41 @@ describe("split panes", () => {
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
expect(screen.getByTestId("pane-1")).toHaveAttribute("data-focused", "true");
expect(screen.getByTestId("pane-header-0")).toBeInTheDocument();
// The other pane's tab is marked as shown; the focused one is the active tab.
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-0").parentElement).toHaveAttribute("data-shown", "true");
expect(stored().root).toMatchObject({ type: "split", dir: "row" });
// The two tabs became ONE tab holding both panes.
expect(screen.queryByTestId("console-tab-1")).toBeNull();
expect(screen.getByTestId("console-tab-0-1")).toHaveAttribute("aria-selected", "true");
expect(stored().groups).toHaveLength(1);
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
});
it("a plain click focuses a tab another pane shows, and replaces the focused pane otherwise", async () => {
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-2"));
await settle();
expect(shownPanes()).toEqual(["pane-1", "pane-2"]);
expect(screen.getByTestId("pane-2")).toHaveAttribute("data-focused", "true");
expect(shownPanes()).toEqual(["pane-2"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-2");
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("closing a pane keeps its session as a tab; closing a tab closes its pane", async () => {
it("a tab holds at most four panes; a fifth split opens a tab of its own", async () => {
withRuns([live(0), live(1), live(2), live(3), live(4)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
for (const s of [1, 2, 3]) await ctrlClick(screen.getByTestId(`console-tab-${s}`));
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
await ctrlClick(screen.getByTestId("console-tab-4"));
expect(shownPanes()).toEqual(["pane-4"]);
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
});
it("closing a pane takes it out into a tab of its own; closing a tab stops every session in it", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
@@ -731,12 +749,13 @@ describe("split panes", () => {
expect(api.run.kill).not.toHaveBeenCalled();
expect(screen.getByTestId("console-tab-1")).toBeInTheDocument();
withRuns([live(0), live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-2"));
withRuns([live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
await settle();
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(shownPanes()).toEqual(["pane-0"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("drops a remembered pane whose session is gone", async () => {
@@ -812,7 +831,7 @@ describe("split panes", () => {
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-3"]);
await right(screen.getByTestId("pane-header-1"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Move to own tab"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
expect(api.run.kill).not.toHaveBeenCalled();
@@ -896,7 +915,7 @@ describe("split panes", () => {
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
await settle();
expect(screen.getByTestId("run-param").textContent).toBe("");
expect(stored().root).toEqual({ type: "pane", slot: 0 });
expect(stored().groups[0].root).toEqual({ type: "pane", slot: 0 });
// The last one too: still the home.
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
await settle();
@@ -908,7 +927,7 @@ describe("split panes", () => {
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
withRuns([]);
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
});
@@ -937,8 +956,8 @@ describe("split panes", () => {
expect(term("agenthub-lane-1")).toHaveAttribute("data-autofocus", "false");
});
it("offers no new terminal in a split once every slot is busy", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
it("offers no new terminal in a split once all ten slots are busy", async () => {
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("pane-split-right-1"));
@@ -2618,6 +2618,32 @@ exports[`screen snapshots > Claude Config 1`] = `
type="text"
value="/home/test/.claude"
/>
<button
aria-label="Browse for a folder"
class="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
title="Browse for a folder"
type="button"
>
<svg
class="lucide lucide-folder-open h-3.5 w-3.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="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
</button>
<div
class="contents"
/>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
@@ -5452,7 +5478,7 @@ exports[`screen snapshots > Run 1`] = `
<code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
>
agenthub lanes add --cwd $(pwd)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
@@ -6955,7 +6981,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.0.1 (rebuild client to match)
UI build v2.1.0 (rebuild client to match)
</p>
</div>
<div
@@ -9935,7 +9961,7 @@ exports[`screen snapshots > Workflows 1`] = `
No workspace has recorded a stage yet. Create one with
<code>
agenthub lanes add --cwd $(pwd)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
+54 -13
View File
@@ -108,6 +108,13 @@ https://dashboard.example.com
### Workspaces
Every `/api/lanes…` route below also answers under `/api/workspaces…` — the
same router mounted twice (`server/index.js`), so `GET /api/workspaces/:id` is
`GET /api/lanes/:id`. Both are permanent: `/api/workspaces` matches the UI's
word, but exists only on servers newer than v2.0.1, so a script that must also
reach an older install keeps `/api/lanes`. Responses keep their `lane`/`lanes`
keys either way, and the paths below are written with `/api/lanes`.
Workspace mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
`PATCH /api/lanes/:id`, confirmed
actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and
@@ -170,6 +177,32 @@ Two concurrent calls for the same path always yield **one** workspace: the
`lanes.cwd` UNIQUE constraint decides the winner, and the loser re-reads and
returns the winning workspace rather than reporting a conflict.
#### Pick a path with the OS's own dialog
```http
POST /api/lanes/pick-path
Content-Type: application/json
{ "kind": "dir", "start": "/home/me/projects" }
```
Opens the operating system's folder (`kind: "dir"`, the default) or file
(`kind: "file"`) picker **on the server's desktop** and waits for the user —
zenity, else kdialog, on Linux; `osascript` on macOS; PowerShell's WinForms
dialogs on Windows (`server/lib/native-dialog.js`; macOS and Windows untested).
`start` opens it there (its folder when it names a file; home when missing).
Answers `{ "path": "<absolute>" }` or `{ "cancelled": true }`. Same-origin
guarded, and only a socket from loopback carrying no `X-Forwarded-For` /
`Forwarded` header may ask (a reverse proxy connects from loopback too):
`403 EREMOTE` otherwise, `501 ENODIALOG` with no graphical session, no picker
program, or one that could not reach the display, `409 EBUSY` while one is
already open, `500 EDIALOG` when the program failed. The dialog is closed when
the request goes away (a closed tab, an SSH tunnel's browser leaving). A folder
**Browse** button in the UI (`client/src/components/PathPickButton.tsx`) falls
back on ANY of those to the in-app folder browser
`GET /api/lanes/browse?path=<abs>`; a file field falls back to typing the path
on `EREMOTE`/`ENODIALOG` and shows the error otherwise.
#### Update a workspace
```http
@@ -193,7 +226,7 @@ to showing the workspace's `cwd`. Renaming is display-only: a workspace's branch
slugified from its title once, at creation.
`pipeline` is patchable, which is how a workspace moves between templates after
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
creation (`agenthub workspace pipeline <template-id>`). An id no template defines is
rejected with `400 EBADPIPELINE` rather than stored (`default` is always
accepted): `getPipeline` falls back to the default template when READING, so an unvalidated write would be
accepted and then silently render the wrong map forever. Same 400 treatment as
@@ -281,8 +314,8 @@ When the session's tool is Codex, three fields control Codex-specific behavior:
- **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
- **`slot`** — which Workspace tab's session to start, `0`–`3` or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all four are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): tmux's default shell in the workspace's folder, recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
- **`slot`** — which Workspace session to start, `0`–`9` (ten per workspace) or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): the login shell tmux would run (`$SHELL -l`) in the workspace's folder, started only once a client is attached, so at the browser's size (`#{session_attached}` polled, at most 2 s — a prompt printed at tmux's 80 columns and then resized left the cursor on top of it, issue #55), recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
@@ -526,14 +559,15 @@ that file.
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
PUT /api/lanes/:id/git/file {path, content, baseSha}
GET /api/lanes/:id/git/raw?path=<image>[&rev=work|HEAD|<sha>]
GET /api/lanes/:id/git/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir>
GET /api/lanes/:id/git/tree-all
```
The reads behind the Changes panel's **History**, whole-file view, quick-open
and file explorer. All four are read-only and unguarded, and all four answer
`{available:false}` with HTTP **200** for a cwd git cannot read.
and file explorer. The GETs are read-only and unguarded, and the JSON ones
answer `{available:false}` with HTTP **200** for a cwd git cannot read.
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
@@ -547,6 +581,13 @@ and file explorer. All four are read-only and unguarded, and all four answer
overwrite. `403 ESECRET` for `settings.local.json`; `400 EBADPATH`,
`ENOFILE`, `EBADCONTENT`, `ETOOBIG`, `EBINARY`. Answers the file re-read,
fresh `sha` included. CRLF endings and the final newline are preserved.
- **`/git/raw`** → the bytes of one IMAGE file (png, jpg, jpeg, gif, webp,
avif, bmp, ico, svg — by extension, which also sets the Content-Type), for
the viewer's `<img>`; the Changes panel shows an image file this way instead
of text or a binary diff. Same path/rev/secret guards as `/git/file`, 20 MB
cap; `400 ENOTIMAGE` for any other extension, `ETOOBIG`, `ENOFILE`,
`EBADPATH`, `EBADREV`, `403 ESECRET`. Served with a `sandbox` CSP and
`nosniff`, so an SVG opened from this URL runs no script.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines, sha?}`
(`sha` only for a whole, textual `work` file: what a save sends back).
`rev=work` (default) is the file on disk — the one branch that reads the
@@ -994,7 +1035,7 @@ Delete proof at one of three granularities: request body `{ "group": "phase-name
POST /api/lanes/:id/proof-link
```
Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`) onto the canonical `.playwright-mcp/proof`. This is an explicit operation, never automatic — exposed only as `agenthub lanes proof-link`. Idempotent.
Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`) onto the canonical `.playwright-mcp/proof`. This is an explicit operation, never automatic — exposed only as `agenthub workspace proof-link`. Idempotent.
**Response:**
@@ -1110,7 +1151,7 @@ every number. `status` filters by session lifecycle, same convention as
```json
{
"skills": [
{ "name": "agenthub:repo-onboarding", "plugin": "agenthub", "count": 12, "sessions": 5 },
{ "name": "agenthub:update-project-docs", "plugin": "agenthub", "count": 12, "sessions": 5 },
{ "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
],
"plugins": [
@@ -1941,10 +1982,10 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
#### Total Cost
```http
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&date=<YYYY-MM-DD>
```
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`; the status bar's **Today** is the entry for today). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
```bash
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
@@ -2276,7 +2317,7 @@ GET /api/run List all live runs (computed fresh from tm
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
@@ -2295,7 +2336,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
`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.
**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 workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is flipped to `abandoned` once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
@@ -2368,7 +2409,7 @@ The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
**Featured pets are downloaded, never bundled.** The four offered (Goku, Bac Ho, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
---
@@ -2550,7 +2591,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 workspace 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.
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace 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 workspace 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 workspace 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
+17 -15
View File
@@ -229,15 +229,17 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. `agenthub lanes …` and `--lane <id>` are the old names and keep working — the same commands as `agenthub workspace …` and `--workspace <id>` — so scripts and skills written before the rename need no change.
| Command | Description |
| ------- | ----------- |
| `agenthub lanes` | List workspaces with stage, status, liveness and progress |
| `agenthub lanes add --cwd <path> --title <text> [--tool <id>]` | Adopt an existing directory as a workspace; `--tool` is an AI Tools id (default `claude`) |
| `agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]` | Provision a dashboard-managed git worktree as a new workspace |
| `agenthub lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.agenthub/profile/`. Refuses to overwrite an existing one without `--force` |
| `agenthub lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a workspace renders against, plus the available templates and their node counts (none until you install one in `DASHBOARD_PIPELINES_DIR`); pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the workspace's current stage unresolvable warns |
| `agenthub lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a workspace id) |
| `agenthub lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated workspace's database |
| `agenthub workspaces` | List workspaces with stage, status, liveness and progress |
| `agenthub workspace add --cwd <path> --title <text> [--tool <id>]` | Adopt an existing directory as a workspace; `--tool` is an AI Tools id (default `claude`) |
| `agenthub workspace add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]` | Provision a dashboard-managed git worktree as a new workspace |
| `agenthub workspace profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.agenthub/profile/`. Refuses to overwrite an existing one without `--force` |
| `agenthub workspace pipeline [<template-id>] [<id>]` | Show which pipeline template a workspace renders against, plus the available templates and their node counts (none until you install one in `DASHBOARD_PIPELINES_DIR`); pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the workspace's current stage unresolvable warns |
| `agenthub workspace profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a workspace id) |
| `agenthub workspace reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated workspace's database |
| `agenthub stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the workspace's current pipeline stage. Called by a skill at each phase boundary. With no pipeline template installed (AgentHub ships none — see [Custom pipeline templates](LANES.md#custom-pipeline-templates)) it answers `400 ENOPIPELINE`. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` workspace sharing one directory |
| `agenthub feature list [<id>]` | List every feature this workspace has activated, archived or live |
| `agenthub feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
@@ -247,19 +249,19 @@ A workspace is a durable unit of parallel agent work — one working directory,
| Command | Description |
| ------- | ----------- |
| `agenthub lanes up [<id>] [--no-build]` | Boot the stack through the profile's `boot` + `health` hooks, then poll until it is healthy or the boot fails. `--no-build` reuses an existing build |
| `agenthub lanes down [<id>]` | Stop the stack. Idempotent, and a no-op for a workspace that was never up |
| `agenthub lanes runtime [<id>]` | Slot, ports (flagging any that stepped aside from its base), the workspace's database name and Redis index when its profile declares them (see [Data isolation](LANES.md#data-isolation-database-redis-and-env-a2)), per-service liveness, log paths, and the last boot error |
| `agenthub lanes logs [<id>] <service> [--tail N]` | Tail one service or hook log (`--tail` in bytes, default 64 KiB) |
| `agenthub lanes hook [<id>] <name> [args…]` | Run one of the profile's hooks: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop` |
| `agenthub lanes proof-link [<id>]` | Converge clone-root `proof/` onto `.playwright-mcp/proof` (idempotent, never run automatically) |
| `agenthub workspace up [<id>] [--no-build]` | Boot the stack through the profile's `boot` + `health` hooks, then poll until it is healthy or the boot fails. `--no-build` reuses an existing build |
| `agenthub workspace down [<id>]` | Stop the stack. Idempotent, and a no-op for a workspace that was never up |
| `agenthub workspace runtime [<id>]` | Slot, ports (flagging any that stepped aside from its base), the workspace's database name and Redis index when its profile declares them (see [Data isolation](LANES.md#data-isolation-database-redis-and-env-a2)), per-service liveness, log paths, and the last boot error |
| `agenthub workspace logs [<id>] <service> [--tail N]` | Tail one service or hook log (`--tail` in bytes, default 64 KiB) |
| `agenthub workspace hook [<id>] <name> [args…]` | Run one of the profile's hooks: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop` |
| `agenthub workspace proof-link [<id>]` | Converge clone-root `proof/` onto `.playwright-mcp/proof` (idempotent, never run automatically) |
| `agenthub lock status [<name>]` | Show one lock's holder, or every currently-held lock |
| `agenthub lock acquire <name> [--holder X] [--timeout N]` | Acquire a cross-workspace named lock, polling until free (or `--timeout` seconds elapse). Holder defaults to the calling workspace (`lane<slot>`) |
| `agenthub lock release <name> [--holder X]` | Release a lock. Refused (409) when `--holder` doesn't match the current owner |
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub lanes logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
Because a workspace's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
+1 -1
View File
@@ -140,7 +140,7 @@ graph TB
- Node.js >= 20.0.0
- npm >= 9.0.0
- git — every lane operation shells out to it
- tmux — only for the browser run console and `agenthub lanes shell`; both
- tmux — only for the browser run console and `agenthub workspace shell`; both
execute the binary directly and have no fallback. Per-OS install commands and
the two-sided check (your PATH vs the server's) are in
[INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages).
+79 -78
View File
@@ -14,10 +14,10 @@ A workspace persists across session restarts, which is what makes a pipeline vie
## Creating a workspace
Create a workspace bound to a working directory with `agenthub lanes add`:
Create a workspace bound to a working directory with `agenthub workspace add`:
```bash
agenthub lanes add --cwd /path/to/repo --title "My Feature"
agenthub workspace add --cwd /path/to/repo --title "My Feature"
```
The workspace is now ready to track work. When you run a Claude Code session in that directory (or a subdirectory), the dashboard will bind the session to the workspace automatically by matching the cwd.
@@ -27,21 +27,21 @@ The workspace is now ready to track work. When you run a Claude Code session in
To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository:
```bash
agenthub lanes add --repo /path/to/repo --title "My Feature" --base main --slug my-feature
agenthub workspace add --repo /path/to/repo --title "My Feature" --base main --slug my-feature
```
`--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready workspace or its failure notes.
Adopting your main repo (`agenthub lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that workspace's own card lists every managed-worktree workspace whose `source_repo` matches its `cwd`, each a clickable link to jump to that workspace.
Adopting your main repo (`agenthub workspace add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree (once a pipeline template is installed) — see "Stage detection" below. Once adopted, that workspace's own card lists every managed-worktree workspace whose `source_repo` matches its `cwd`, each a clickable link to jump to that workspace.
Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs, best-effort, in parallel: `agenthub lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub lanes mcp sync`, and the AI Router pick. None of the three blocks the workspace from being created or from each other — a workspace whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the workspace's own card, or the CLI) if the automatic attempt didn't apply.
Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs, best-effort, in parallel: `agenthub workspace profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `agenthub workspace mcp sync`, and the AI Router pick. None of the three blocks the workspace from being created or from each other — a workspace whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the workspace's own card, or the CLI) if the automatic attempt didn't apply.
### The "+ Add workspace" modal
The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`).
- **Repo** — adopts the given directory as-is (maps to `agenthub workspace add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub workspace add --repo`).
In Worktree mode the branch question has three answers, because git's own rule
decides which is available (issue #34):
@@ -83,13 +83,13 @@ this field wants had to be retyped every time.
The modal has **no pipeline picker**: every workspace it creates is born on
`default` (the server's own default), and the template is changed afterwards
from the workspace's own detail header in Workspace, or at creation from the
terminal with `agenthub lanes add --pipeline <id>`. *Model endpoint* is the one
terminal with `agenthub workspace add --pipeline <id>`. *Model endpoint* is the one
per-workspace extra the form still asks for, and it sits in the form itself rather
than behind a disclosure — a disclosure around a single field only hid it.
A workspace has no AI Router of its own: each NEW session picks one. The **AI Router** select in the workspace home's prompt box lists the profiles saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a router (only Claude and Codex can), and it remembers the last pick (`agenthub.session.router`). The start sends `router: "<name>"`; a Claude pane then runs with `--settings=<data dir>/router-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two routers, a worktree session gets its router too, and switching never touches a running session. A resume goes back to the router its session last ran on (`dashboard_runs.router`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's router into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Router profiles.
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.
Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the small in-app folder browser instead (`GET /api/lanes/browse?path=<abs>`): click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker, no in-app fallback).
## Workspace tool: Claude or Codex
@@ -132,9 +132,9 @@ the file if nothing else is in it).
Reset a managed worktree, remove one, or purge the workspace's eligible session history with the CLI:
```bash
agenthub lanes reset <id> [--force] [--keep-db] --yes
agenthub lanes remove <id> [--force] --yes
agenthub lanes purge <id> --yes
agenthub workspace reset <id> [--force] [--keep-db] --yes
agenthub workspace remove <id> [--force] --yes
agenthub workspace purge <id> --yes
```
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated workspace's **reset** will drop — `remove` drops no database at all; see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits — it applies to `reset` alone, since `remove` deletes nothing that could hold them. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the workspace's database exactly as it was. An adopted workspace points at a directory you own, so the CLI refuses to `reset` it. `remove` is allowed for **every** workspace kind, adopted or managed, because it drops only the dashboard's record and leaves the working tree, its branch, and its database (if any) untouched — deleting those is yours to do by hand.
@@ -143,8 +143,8 @@ Each command first fetches and prints its preflight counts. `reset` and `remove`
### The two workspace kinds
- **`managed`** — a git worktree the dashboard itself created (`agenthub lanes add --repo`), rooted under `LANES_ROOT`. The dashboard may reset that worktree to its base branch. It does NOT delete it: removing the workspace forgets the dashboard's record and leaves the directory and its branch on disk.
- **`adopted`** — an existing directory you already had (`agenthub lanes add --cwd`). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted workspace drops the dashboard's record of it and leaves the directory untouched.
- **`managed`** — a git worktree the dashboard itself created (`agenthub workspace add --repo`), rooted under `LANES_ROOT`. The dashboard may reset that worktree to its base branch. It does NOT delete it: removing the workspace forgets the dashboard's record and leaves the directory and its branch on disk.
- **`adopted`** — an existing directory you already had (`agenthub workspace add --cwd`). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted workspace drops the dashboard's record of it and leaves the directory untouched.
### The three independent safety checks
@@ -194,9 +194,9 @@ Every destructive `POST /api/lanes/:id/:action` must echo back an `expect` objec
### Limitation: a fresh worktree has no dependencies
`agenthub lanes add --repo` runs `git worktree add` only. It does not run `npm install` (or any other package manager), and it does not copy the source repo's untracked local files (`.env`, IDE config, etc.) into the new worktree. A managed workspace is ready for `git` immediately but needs its own dependency install and local env setup before a session can run the project inside it.
`agenthub workspace add --repo` runs `git worktree add` only. It does not run `npm install` (or any other package manager), and it does not copy the source repo's untracked local files (`.env`, IDE config, etc.) into the new worktree. A managed workspace is ready for `git` immediately but needs its own dependency install and local env setup before a session can run the project inside it.
A repository can close the dependency half of that gap itself by declaring a `bootstrap` hook in its profile (below) and running `agenthub lanes hook bootstrap`. Seeding a workspace's `.env` and its database is handled automatically once the profile opts in — see "Data isolation" below.
A repository can close the dependency half of that gap itself by declaring a `bootstrap` hook in its profile (below) and running `agenthub workspace hook bootstrap`. Seeding a workspace's `.env` and its database is handled automatically once the profile opts in — see "Data isolation" below.
## Workspace runtime: running a workspace's own stack
@@ -238,15 +238,15 @@ AgentHub resolves the profile from the **workspace's own working copy first**, t
### Scaffolding a profile automatically
```bash
agenthub lanes profile init <repo> [--force] # detect + write .agenthub/profile/
agenthub lanes profile check [<path>] # validate one (path defaults to cwd)
agenthub workspace profile init <repo> [--force] # detect + write .agenthub/profile/
agenthub workspace profile check [<path>] # validate one (path defaults to cwd)
```
`profile init` currently detects **Node.js only**, in exactly two layouts: a root `package.json` (single-service), or `backend/package.json` **and** `frontend/package.json` both present (monorepo, checked first). Anything else — Python/Go/Ruby, a pnpm/yarn/turborepo workspace, non-standard directory names — is refused with an actionable message rather than guessed at; write `.agenthub/profile/` by hand for those, following the reference above.
Detection reads `package.json` (which of `start`/`dev`/`preview` exists) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`), never executes anything, and never guesses a migration tool: a detected database gets `migrate.sh`/`seed.sh` scaffolded as an explicit, always-`exit 0` TODO stub, not a guessed Prisma/Knex/TypeORM command. `profile check` is the hard gate — it fails (non-zero exit, every problem listed) on any leftover `TODO:`, any missing or non-executable hook, or a declared port already in use; it only *warns* when `~/.agenthub/secrets.env` doesn't exist yet for a database-declaring profile.
`profile check` takes a **path**, not a workspace id or `--lane`/`--cwd` flag like every other `lanes` subcommand — it is meant to run against a bare repository right after `init`, before any workspace or worktree exists for it.
`profile check` takes a **path**, not a workspace id or `--workspace`/`--cwd` flag like every other `workspace` subcommand — it is meant to run against a bare repository right after `init`, before any workspace or worktree exists for it.
### Slots and ports
@@ -296,12 +296,12 @@ Runnable hook names are a fixed allowlist: `bootstrap`, `boot`, `health`, `migra
### Up, down, and surviving a restart
```bash
agenthub lanes up # boot the lane owning this directory
agenthub lanes up 3 --no-build # boot lane 3, reusing an existing build
agenthub lanes runtime # slot, ports, service liveness, last boot error
agenthub lanes logs api # tail one service or hook log
agenthub lanes down # stop the stack
agenthub lanes hook ci-gate # run any allowlisted hook
agenthub workspace up # boot the lane owning this directory
agenthub workspace up 3 --no-build # boot lane 3, reusing an existing build
agenthub workspace runtime # slot, ports, service liveness, last boot error
agenthub workspace logs api # tail one service or hook log
agenthub workspace down # stop the stack
agenthub workspace hook ci-gate # run any allowlisted hook
```
`up` runs `boot` then `health`. It deliberately does **not** run `bootstrap` — installing dependencies on every boot would make a routine restart minutes long. A failing health check **leaves the processes running**: their logs are what tell you which service never came up, and killing them to report a tidy failure destroys the evidence.
@@ -378,7 +378,7 @@ Parsed with the same literal `KEY=VALUE` reader as `profile.env` — never sourc
**When each step runs:**
- **Provisioning** a new managed worktree (`agenthub lanes add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once.
- **Provisioning** a new managed worktree (`agenthub workspace add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once.
- **`up`** — repair `.env` (a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does), `migrate` on every boot (a workspace's schema drifts while it sits idle), and `seed` only on the boot that actually created the database.
- **`reset`** — refresh `.env` with `--force` (preserving `ENV_PRESERVE` keys from the workspace's own file — swapping in the source's `JWT_SECRET` would 401 a running workspace's tokens until reboot), re-run `bootstrap` (a reset can land on a branch with new dependencies), clear the declared `LANE_DIRS`, then drop, recreate, migrate and reseed the database — unless `--keep-db`, which skips that whole block.
- **`remove`** — drops **nothing**. `removeLaneData` still exists in `server/lib/lane-runtime.js` (and still refuses an adopted workspace), but the `remove` action no longer calls it: a workspace's database is the user's data and outlives the dashboard's record of it. Drop it yourself when you actually want it gone.
@@ -391,32 +391,32 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a tab in slots 1–3 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Router) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row opens a workspace's tabs when it has live ones — on the pane it last focused (`agenthub.workspace.paneLayout`) — and its home when it has none; the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) 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 workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub workspace add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering** — drag a row and drop it anywhere in the list (a line shows where it lands, by the pointer's position — on another row, on a session list, above the first or below the last), or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub workspaces` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) 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 folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
- **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 lanes mcp sync` and `agenthub lanes sync-base`, and a readme refresh is a skill typed into a session.
- **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).
- **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=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or 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 four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. 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.)
- **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, split to the right (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. 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. 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.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace 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`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
**A console pane follows the workspace it shows.** Selecting another workspace on the workspace list swaps every pane's cwd, run history and terminal over to the new workspace. If the new workspace already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane is empty. Nothing of the previous workspace (a half-typed prompt, its terminal) carries over.
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the workspace's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the workspace's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub workspace shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub workspace shell` answered `200` while doing nothing at all.
**A workspace whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The dashboard starts a session only in a slot with no live run (the home, a resume and the tab bar's `+` all send `slot: "auto"`, so the SERVER picks the first free one under the workspace lock; `409 ERUNLIVE` when all four are busy), and a tab's **×** is what frees a slot (`DELETE /api/run/:id`); the workspace's own `run_id`/`status` self-heal to idle the next time anything reads the workspace (`healRunId` in `routes/lanes.js`), so no separate workspace-side call is needed.
@@ -630,7 +630,8 @@ reads the workspace's git surface in five modes:
renamed), and a folder holding any of them gets an amber dot. The colours
come with the tree read, so **Refresh** re-reads them.
Opening a file entry shows the whole file (`/git/file`) for reading, at every
panel width. **Clicking its text** (or Enter on it; any range but a commit)
panel width; an image file (png, jpg, gif, webp, avif, bmp, ico, svg) is shown
as the picture (`/git/raw`), with no editor. **Clicking its text** (or Enter on it; any range but a commit)
turns that view into a text editor with the caret on the clicked line and
the scroll position kept — a click that only ends a drag-selection does not,
so copying never drops you into the editor. **Cancel** returns to reading.
@@ -731,7 +732,7 @@ expensive shapes. Diffs are capped server-side (300 files, 200 KB per file,
List all workspaces with their current status:
```bash
agenthub lanes
agenthub workspaces
```
This shows each workspace's:
@@ -755,13 +756,13 @@ The dashboard web UI shows each workspace as a card in a grid, with the selected
A workspace moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below).
**A workspace is created on `default` unless told otherwise.** The "+ Add workspace" modal does not ask — it always creates on `default`. Pick another with `agenthub lanes add --pipeline <id>` at creation, or change it afterwards from the *Pipeline template* select in the workspace's detail header in Workspace.
**A workspace is created on `default` unless told otherwise.** The "+ Add workspace" modal does not ask — it always creates on `default`. Pick another with `agenthub workspace add --pipeline <id>` at creation, or change it afterwards from the *Pipeline template* select in the workspace's detail header in Workspace.
For a workspace that already exists, switch it from the terminal:
```bash
agenthub lanes pipeline # which template this lane uses, and what else exists
agenthub lanes pipeline ml-training # switch it (any installed template id)
agenthub workspace pipeline # which template this lane uses, and what else exists
agenthub workspace pipeline ml-training # switch it (any installed template id)
```
or from the dashboard: the pipeline-template select next to the workspace's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot).
@@ -773,10 +774,10 @@ A template may name the slash command that drives it (its optional `skill` field
When you only want one step — re-run a CI gate, redo a brainstorm — run that node's own `command`:
```bash
agenthub lanes node # list every node of this lane's pipeline and its command
agenthub lanes node <node-id> # run that node's command in the lane's cwd
agenthub lanes node <node-id> extra words # extra words are appended to the prompt
agenthub lanes node <node-id> --print # show the argv, run nothing
agenthub workspace node # list every node of this lane's pipeline and its command
agenthub workspace node <node-id> # run that node's command in the lane's cwd
agenthub workspace node <node-id> extra words # extra words are appended to the prompt
agenthub workspace node <node-id> --print # show the argv, run nothing
```
A node is runnable when its template gives it a `command`. A command starting with `/` is spawned as `claude "<command> <extra args>"`; anything else is spawned directly. The workspace comes from the working directory as usual, and the command always runs in the workspace's own `cwd`. The node is matched by **id or alias**.
@@ -832,7 +833,7 @@ offering every other workspace even while the counts show only one.
## Attaching a real terminal to a workspace
`agenthub lanes shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this workspace (`agenthub-lane-<id>`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live.
`agenthub workspace shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this workspace (`agenthub-lane-<id>`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live.
The dashboard renders every node in the pipeline in one of five **states**:
@@ -872,7 +873,7 @@ This command:
### Full command signature
```bash
agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
agenthub stage <stage> [--workspace <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
```
**Parameters:**
@@ -892,7 +893,7 @@ agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>]
The check is skipped for `--result fail`, which paints the node `failed`
rather than `current`.
- `--lane <id>` (optional): the numeric workspace ID. If omitted, the command resolves the workspace by `cwd`.
- `--workspace <id>` (optional): the numeric workspace ID. If omitted, the command resolves the workspace by `cwd`.
- `--cwd <path>` (optional): working directory to match against a workspace's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
@@ -954,7 +955,7 @@ Every cwd-resolving `agenthub` command (`stage`, `lanes pipeline`, `feature
activate`, `lanes up/down/logs`, …) shares one resolver, `resolveLaneArg` in
`bin/agenthub.js`. It answers in this order:
1. `--lane <id>`, or a leading numeric positional.
1. `--workspace <id>`, or a leading numeric positional.
2. `AGENTHUB_LANE_ID` in the environment — every tmux pane the dashboard starts
carries it (`tmux new-session -e`, or an assignment prefix when the argv is
typed into a pane already at a shell prompt). A session inside a
@@ -973,7 +974,7 @@ directory and no signal from steps 1-3, the command **fails** and names both
workspaces rather than picking one:
```
2 lanes own /home/me/repo — pass --tool <claude|codex> or --lane <id>: #23 (claude), #33 (codex)
2 workspaces own /home/me/repo — pass --tool <claude|codex> or --workspace <id>: #23 (claude), #33 (codex)
```
That is deliberate. Silently taking the first would write a codex session's
@@ -1036,7 +1037,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.agenthub/pipelin
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
- **icon** (string, optional): unicode emoji or single character shown before the stage name
- **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false`
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a workspace card and as copyable text per node in Dashboard → Analytics → Workflow, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub workspace hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a workspace card and as copyable text per node in Dashboard → Analytics → Workflow, and run by `agenthub workspace node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`). An alias may also name a **sub-state** — *why* the workspace sits on a node — without adding a node to the map. Two constraints: no alias may collide with another node's id or alias (`phaseIdx` takes the FIRST match, so a duplicate silently resolves a declaration onto the wrong node), and all aliases of one node share one record slot (`stageRecords` resolves each declared key onto its node, and the canonical id's record wins), so a sub-state that needs its own `--evidence` has to be a real node.
- **detect** (array, optional): stage-detection rules for this node — see "Stage detection → Where the rules live".
@@ -1049,7 +1050,7 @@ export DASHBOARD_PIPELINES_DIR=~/.agenthub/pipelines
When you create a new workspace, you can optionally specify which pipeline to use:
```bash
agenthub lanes add --cwd /path/to/ml-repo --title "Training run #1" --pipeline ml-training
agenthub workspace add --cwd /path/to/ml-repo --title "Training run #1" --pipeline ml-training
```
(The pipeline is selected at creation time. Workspaces created without `--pipeline` default to the `default` pipeline — yours if you installed one with that id, otherwise the empty one.)
@@ -1194,7 +1195,7 @@ key back onto its node, so a stage declared by alias keeps its record — and it
`--evidence` — instead of reading as an inference or losing its `done`. Where a
node has both a canonical record and an alias record, the canonical one wins.
The same rule governs `agenthub lanes`: the inferred stage is printed only when it
The same rule governs `agenthub workspaces`: the inferred stage is printed only when it
leads the declared one (see "Viewing workspaces" below), and the PipelineMap's
amber detection badge follows the identical condition — the terminal and the
browser never disagree about which one is the headline. Both locate the
@@ -1263,7 +1264,7 @@ command: it's whichever of the fields above the matching rule's `tool` carried.
Detection only ever attributes to a workspace whose `cwd` matches the hook's
session `cwd` (see "Which workspace a signal is credited to" above). A session
working directly in a source repo that was never itself adopted as a workspace —
run `agenthub lanes add --cwd $(pwd)` from that repo to fix that — gets no
run `agenthub workspace add --cwd $(pwd)` from that repo to fix that — gets no
detection at all, because no workspace owns that `cwd`.
### Detection expires
@@ -1395,12 +1396,12 @@ Set the environment variable to change when a watcher is considered dead:
export LANE_DEAD_SEC=600 # 10 minutes instead of 5
```
## Housekeeping: agenthub lanes gc
## Housekeeping: agenthub workspace gc
Runs machine-wide, not against one workspace — safe to run any time, running workspaces and live sessions are unaffected:
```bash
agenthub lanes gc [--dry-run]
agenthub workspace gc [--dry-run]
```
Two things, both scoped to what's actually stale:
@@ -1408,7 +1409,7 @@ Two things, both scoped to what's actually stale:
- **Reaps orphaned Playwright MCP processes.** A `.playwright-mcp`-scoped process whose owning Claude Code session died gets reparented to pid 1 — a live session's MCP keeps its real parent and is left alone. Kills the whole process tree so a spawned browser doesn't leak too.
- **Caps oversized hook logs.** Any `LANES_ROOT/.state/lane<N>/logs/*.log` over 10MB is truncated in place to its last 2MB (same inode — a concurrent writer's file descriptor stays valid).
`--dry-run` prints what would happen without doing it. This is a deliberately narrower port of Shipyard's `lane-gc.sh` — it does NOT auto-remove stale worktrees by age (that would violate this repo's own never-automatic-destroy rule; use `agenthub lanes reset|remove|purge` explicitly instead), archive feature state (AgentHub's workspace/feature state lives in the database, not flat files), or sweep scratch debris (AgentHub doesn't generate the files Shipyard's own scripts did). See `docs/superpowers/specs/2026-08-05-lane-gc-design.md` for the full scoping rationale.
`--dry-run` prints what would happen without doing it. This is a deliberately narrower port of Shipyard's `lane-gc.sh` — it does NOT auto-remove stale worktrees by age (that would violate this repo's own never-automatic-destroy rule; use `agenthub workspace reset|remove|purge` explicitly instead), archive feature state (AgentHub's workspace/feature state lives in the database, not flat files), or sweep scratch debris (AgentHub doesn't generate the files Shipyard's own scripts did). See `docs/superpowers/specs/2026-08-05-lane-gc-design.md` for the full scoping rationale.
## Workspace actions
@@ -1416,7 +1417,7 @@ The web UI and CLI provide these actions on a workspace:
### start
Start a tmux-backed run attached to the workspace. The Workspace page's "Start Run" button calls `POST /api/run` with the workspace's id; you can also attach from a real terminal using `agenthub lanes shell`.
Start a tmux-backed run attached to the workspace. The Workspace page's "Start Run" button calls `POST /api/run` with the workspace's id; you can also attach from a real terminal using `agenthub workspace shell`.
```bash
curl -X POST http://localhost:4820/api/run \
@@ -1424,7 +1425,7 @@ curl -X POST http://localhost:4820/api/run \
-d '{"laneId": 5, "initialPrompt": "continue the work"}'
```
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.
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 workspace shell` seamlessly.
**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.
@@ -1516,7 +1517,7 @@ Proof files live inside the workspace's own `cwd`, not under `LANES_ROOT`:
### Linking proof directories (the `ensure_proof_link` primitive)
An MCP server's `--output-dir` flag may point to different locations across runs. To converge all proof onto one canonical path regardless, AgentHub provides the `ensure_proof_link` operation, exposed only as the explicit `agenthub lanes proof-link` CLI command — it is **never automatic**. This matches the "AgentHub does not orchestrate" rule: a session decides when to link, not the dashboard.
An MCP server's `--output-dir` flag may point to different locations across runs. To converge all proof onto one canonical path regardless, AgentHub provides the `ensure_proof_link` operation, exposed only as the explicit `agenthub workspace proof-link` CLI command — it is **never automatic**. This matches the "AgentHub does not orchestrate" rule: a session decides when to link, not the dashboard.
`ensure_proof_link` works as follows:
@@ -1583,7 +1584,7 @@ The commands below are primitives a driving session (or you) calls explicitly
When the workspace stack needs to run under QC conditions (deterministic, mocked externals), use the `--qc` flag:
```bash
agenthub lanes up --qc
agenthub workspace up --qc
```
This flag activates the workspace's `QC_BOOT_ENV` profile declaration. Define it in the profile's `profile.env` file as space-separated `KEY=value` pairs (the same format as the `PORTS` declaration):
@@ -1595,16 +1596,16 @@ PORT_BASE_app=3000
QC_BOOT_ENV="MOCK_PAYMENTS=1 STUB_EMAIL=1"
```
These environment variables are passed to the profile's `boot` hook during `agenthub lanes up --qc`, ensuring QC test runs are deterministic.
These environment variables are passed to the profile's `boot` hook during `agenthub workspace up --qc`, ensuring QC test runs are deterministic.
### Dev preflight and merge safety: sync-base
`agenthub lanes sync-base` is the ONE sanctioned merge a session runs from the CLI — `origin/development` into a feature branch. Three modes:
`agenthub workspace sync-base` is the ONE sanctioned merge a session runs from the CLI — `origin/development` into a feature branch. Three modes:
```bash
agenthub lanes sync-base --check feat/<slug> # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
agenthub lanes sync-base feat/<slug> # merge origin/development into the feature branch
agenthub lanes sync-base --continue feat/<slug> # finish after a manually resolved conflict
agenthub workspace sync-base --check feat/<slug> # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
agenthub workspace sync-base feat/<slug> # merge origin/development into the feature branch
agenthub workspace sync-base --continue feat/<slug> # finish after a manually resolved conflict
```
Exit codes: `0` clean, `4` merge conflict (left in place — resolve, commit, then `--continue`), `5` migration-number collision (nothing merged — rename the printed file, re-run).
@@ -1621,10 +1622,10 @@ GENERATED_MERGE_PATHS="api/openapi.json api/client.ts"
### Syncing MCP servers: mcp sync
A workspace needs the same MCP servers (Playwright, a local-QC server) as its source repo to run e2e or browser QC. `agenthub lanes mcp sync` gives it those:
A workspace needs the same MCP servers (Playwright, a local-QC server) as its source repo to run e2e or browser QC. `agenthub workspace mcp sync` gives it those:
```bash
agenthub lanes mcp sync
agenthub workspace mcp sync
```
Reads the source repo's already-configured `mcpServers` from `~/.claude.json` (normal Claude Code project-scope config — set this up for the source repo once, the same way you would for any project), relocates any absolute path under the source repo to the workspace's own directory, pins a `@playwright/mcp` server's `--output-dir` to the workspace's `.playwright-mcp` (so proof screenshots land where the proof gallery reads them), and writes `<lane>/.mcp.json`. Also seeds the workspace's Chromium browser profiles from the source repo's own — preserves saved logins, and never overwrites a profile that already exists at the destination.
@@ -1635,22 +1636,22 @@ A workspace whose source repo has no `mcpServers` configured gets a clear `ENOMC
Restart the workspace's Claude session after syncing — MCP config is read at session start.
### Checking integration toggles: agenthub lanes integration
### Checking integration toggles: agenthub workspace integration
A profile can declare `.agenthub/profile/integrations.env` with `TRACKER_ENABLED`, `DEV_QC_ENABLED`, `CI_WAIT_ENABLED` flags (all off by default — see `profiles/_template/integrations.env`-style declarations in a profile's own docs). Check one:
```bash
agenthub lanes integration tracker # exit 0 = on, 1 = off
agenthub workspace integration tracker # exit 0 = on, 1 = off
```
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. Nothing in AgentHub acts on an enabled `tracker` or `dev_qc` toggle. `ci_wait`'s consumer is `agenthub lanes ci status` (below). The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the workspace card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing.
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. Nothing in AgentHub acts on an enabled `tracker` or `dev_qc` toggle. `ci_wait`'s consumer is `agenthub workspace ci status` (below). The CLI command and `GET /api/lanes/:id/integrations/:name` are the only readouts: the workspace card used to carry a row of three badges for these, and it no longer does — two of the three could never be anything but decoration, and a badge that reports a toggle nothing acts on costs a row of card height to say nothing.
### Post-merge CI status: agenthub lanes ci status
### Post-merge CI status: agenthub workspace ci status
Check GitHub Actions status for a commit SHA or branch ref — one shot, so a session waiting on CI polls it:
```bash
agenthub lanes ci status <branch-or-sha> [<id>]
agenthub workspace ci status <branch-or-sha> [<id>]
```
Exit codes: `0` = all checks passed, `1` = at least one check failed, `3` = checks pending or in progress. The `<id>` parameter is optional and filters results to a specific workspace if provided; if omitted, it's not needed.
@@ -1689,10 +1690,10 @@ The workspace card UI currently has no prompt/message input field. When you clic
## Related commands
```bash
agenthub lanes # List all lanes
agenthub lanes add --cwd <path> --title <text> # Adopt an existing directory
agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]
agenthub lanes reset|remove|purge <id> [--force] --yes
agenthub workspaces # List all workspaces (old name: agenthub lanes, still works)
agenthub workspace add --cwd <path> --title <text> # Adopt an existing directory
agenthub workspace add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]
agenthub workspace reset|remove|purge <id> [--force] --yes
agenthub stage <stage> [--evidence "..."] # Report the current stage
```
@@ -1709,7 +1710,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — ten built in: Claude Code, Codex, Gemini CLI, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Oh My Pi, Hermes and OpenClaw —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — ten built in: Claude Code, Codex, Gemini CLI, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Oh My Pi, Hermes and OpenClaw —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file:
+6 -6
View File
@@ -87,7 +87,7 @@ checkout unnecessary.
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
| `/agenthub-doctor`, `/agenthub-update`, `/agenthub-open` | `plugins/agenthub/commands/` |
| The skills (`repo-onboarding`, `debug-live-issue`, `update-project-docs`, `file-headers`) | `skills/` at the repo root — which IS the plugin root, so they are auto-discovered as plugin skills |
| The skills (`update-project-docs`, `file-headers`) | `skills/` at the repo root — which IS the plugin root, so they are auto-discovered as plugin skills |
Plugin skills are namespaced: they are invoked as `/agenthub:<name>`, with the bare
`/<name>` working as an alias unless another skill or command claims that name.
@@ -172,8 +172,8 @@ So the bootstrap now asks first (`supersededBy`): if Claude Code's
`~/.claude/plugins/installed_plugins.json` records a **sibling** directory as
the installed build of this plugin, this build logs one line and returns
`superseded` without touching dependencies, the unit, or the server. `--force`
does not override it — the self-update chain runs `--force` from the NEW
directory, which is never superseded, so the only `--force` this can refuse is
does not override it — the self-update chain and `/agenthub-update` run
`--force` from the NEW directory, which is never superseded, so the only `--force` this can refuse is
one that would deliberately downgrade.
It cannot hand over to the new build either: the process it would have to
@@ -338,9 +338,9 @@ courtesy of `Restart=always`.
| Command | Does |
|---|---|
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), UI bundle |
| `/agenthub-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@<marketplace> -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. |
| `/agenthub-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes |
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub workspace shell` and session reloads need it), UI bundle. On any `FAIL`/`WARN` it asks whether to fix them, then lists the exact steps (one `plugin-bootstrap.js --force` from the installed build for deps/server/hooks/CLI launcher/stale unit, Node ≥ 22.5 through the user's version manager, the tmux package, the PATH line, …) and runs them only after a second yes; disk, a foreign unit, tmux built from source and an older running tmux server are reported, never touched. `plugin-doctor.js` itself stays read-only |
| `/agenthub-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@<marketplace> -y`, then reinstall dependencies and restart the server with `plugin-bootstrap.js --force` run from the build that is INSTALLED now (`supersededBy()` of this session's root, else that root — the old root's bootstrap would refuse as `superseded`), and run the doctor. The dashboard is on the new version at once; only this session's own commands and skills wait for `/reload-plugins` or a new session. |
| `/agenthub-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes. `plugin-open.js` exits 1 when the build fails or that bootstrap does not answer `ok`, and the command then runs the `/agenthub-doctor` flow and retries the build once after the approved fixes |
### Where things live
+87
View File
@@ -0,0 +1,87 @@
# Retiring the old name "lane" from the CLI and API
_Phase 0 (`docs/plans/workspace-redesign/00-rename-lane-to-workspace.md`)
changed the word in the UI and docs only. This plan takes the user-facing
NAMES of the CLI and API the same way, in four steps spread over releases so
nobody is broken by an update and nothing stored is touched._
**R** = the first release after v2.0.1 that ships steps 1–3 (write its
version here when it is cut: R = `v2.1.0`).
## Steps
| # | What | When | Status |
| --- | --- | --- | --- |
| 1 | Aliases: `/api/workspaces` = `/api/lanes` (same two routers mounted twice, `server/index.js` `LANE_MOUNTS`); `agenthub workspace`/`workspaces` = `agenthub lanes` (`COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`); `--workspace <id>` = `--lane <id>` (`resolveLaneArg`). | R | done |
| 2 | Help, usage and hint text, the UI's command hints and the user docs say `agenthub workspace …` / `--workspace`; `docs/API.md` documents both paths. | R | done |
| 3 | Deprecation: `agenthub lanes` or `--lane` prints ONE stderr line naming the new spelling (`warnOldNames`), stdout unchanged; the help footer and `docs/CLI.md` call them the old names. | R | done |
| 4 | Remove the old CLI names (below). | **not before R+3** | open |
Three releases is the window for an install to update gradually: anyone who
runs the CLI in that time sees the step-3 note on every old-name call, and a
release that skips straight from < R to ≥ R+3 still gets a clear error (4a),
never a silent change of behaviour.
## Step 4 — what it does
**4a. CLI (do this).**
- `agenthub lanes …` no longer runs; it exits 1 with
`renamed: use agenthub workspace …` (not "Unknown command", and not help).
- `--lane <id>` exits 1 with `renamed: use --workspace <id>`.
- The handler is renamed `workspace` in `runCommand`; `COMMAND_ALIASES` keeps
only `workspaces → workspace`; `warnOldNames` goes away.
- Tests: `lanes-cli.test.js` "agenthub workspaces" suite flips to assert the
refusal; every remaining `cli(["lanes", …])` call in the server suites moves
to `workspace`.
**4b. Client and CLI requests move to `/api/workspaces` (do this).**
- `client/src/lib/api.ts` (and the ~105 `/lanes…` request paths) and the 29
`/api/lanes` calls in `bin/agenthub.js` switch to `/api/workspaces`. Safe at
R+3 because every server ≥ R answers it — a CLI updated before its server
restarted (`installed_not_running`) is talking to a server ≥ R by then.
**4c. The `/api/lanes` mount — decide at R+3, default KEEP.**
Removing it saves one array entry and breaks every user script, bookmark and
cached browser tab that still names it. Keep it as a silent, undocumented
compatibility path unless there is a concrete reason. If it is removed,
answer `410 Gone` with `{error:{code:"ERENAMED", message:"use /api/workspaces"}}`
for one more release before dropping it.
## What step 4 must NEVER touch (no data loss)
Step 4 renames only how a request is ROUTED. Nothing below is renamed,
moved or migrated — they are where users' data and running work live:
- SQLite: tables `lanes`, `lane_sources`, `lane_features`,
`lane_stage_events`; columns `lane_id`, `needs_action_session`, …; no
table rename, no column rename, no migration.
- JSON keys in API responses (`lane`, `lanes`, `lane_id`, `counts`) and the
WebSocket message types — an older client or script reads them.
- tmux session names `agenthub-lane-<id>` / `agenthub-lane-<id>-<slot>` —
renaming orphans every live pane (Stop/Reset could no longer reach it).
- Env vars `AGENTHUB_LANE_ID`, `LANES_ROOT`, `LANE_DEAD_SEC`,
`LANE_BRANCH_PREFIX` — a pane started before the update still carries them.
- On-disk paths: `LANES_ROOT` (`~/.claude/agenthub-lanes/`, managed
worktrees), `LANES_ROOT/.state/lane<slot>/`,
`<data dir>/workspace-context/<lane id>/`,
`<data dir>/workspace-prompts/<lane id>.md`, `.agenthub/profile/`.
- Browser storage keys (`agenthub.workspace.*`, `agenthub.sidebar.*`) — a
rename resets everyone's layout and order.
- Error codes (`EBADLANE`, `ERUNLIVE`, …) and file/component names.
## Release checklist for step 4
1. `package.json` version ≥ R+3 (count R from the table above).
2. Release notes, EN + VI, top of the list: "`agenthub lanes` and `--lane`
are removed — use `agenthub workspace` and `--workspace`. Your workspaces,
sessions and history are unchanged."
3. `grep -rn "agenthub lanes\|--lane\b" skills plugins docs README* INSTALL.md`
finds nothing outside `docs/plans/` (a shipped skill calling the old name
would break the moment the plugin updates).
4. Pipeline templates are user files (`DASHBOARD_PIPELINES_DIR`) and may run
`agenthub lanes hook …` as a node command: the release note says so, and
the 4a error names the replacement, so the failing node tells the user
what to edit.
5. Both suites green; open an existing install's DB before and after the
update and compare `SELECT count(*) FROM lanes` / `sessions` / `events`
— step 4 must not change a single row.
+98 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.0.1
version: 2.1.0
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -57,7 +57,7 @@ tags:
- name: Run
description: Spawn and supervise Claude Code runs from the dashboard (headless or interactive conversation), stream output over WebSocket, and manage run history
- name: Lanes
description: Durable parallel-work lanes and dashboard-managed git worktrees
description: Durable parallel-work workspaces (called lanes in the API) and dashboard-managed git worktrees. Every /api/lanes route is also mounted at /api/workspaces — the same handlers, the user-facing name.
- name: Push
description: Web Push notification subscriptions and broadcast (VAPID); also fires native Electron notifications when hosted in the desktop app
- name: AI Tools
@@ -6022,6 +6022,21 @@ paths:
type: string
description: Comma-separated data-source ids to include (local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Narrows the aggregate cost to the given origins.
example: local
- name: lane
in: query
required: false
schema:
type: integer
description: Only the sessions this workspace owns — the same rule as GET /api/sessions?lane=. 400 INVALID_INPUT when not an id.
example: 5
- name: date
in: query
required: false
schema:
type: string
format: date
description: 'Only that local day (YYYY-MM-DD, in `tz_offset`''s timezone): the total and breakdown count the tokens spent that day. 400 INVALID_INPUT for any other shape.'
example: '2026-10-06'
responses:
'200':
description: Cost result
@@ -10574,6 +10589,13 @@ paths:
type: integer
description: Return only the runs started through this lane (POST /api/lanes/{id}/start). Omit for every run; runs spawned from POST /api/run carry no lane and are excluded whenever this is set.
example: 5
- name: tool
in: query
required: false
schema:
type: string
description: Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).
example: shell
responses:
'200':
description: Run history, most recent first
@@ -10946,6 +10968,48 @@ paths:
error:
code: ENOTFOUND
message: run not found
/api/lanes/pick-path:
post:
tags:
- Lanes
summary: Open the OS's own folder/file picker on the server's desktop
description: 'Shows zenity/kdialog (Linux), osascript (macOS) or a WinForms dialog (Windows) on the SERVER''s desktop and waits for the user (up to 10 minutes; closed when the request goes away). Only for a browser on the same machine: a non-loopback client, or a request carrying X-Forwarded-For/Forwarded, gets EREMOTE and should fall back to GET /api/lanes/browse. One dialog at a time. Nothing from the request reaches argv but the starting folder, resolved to an existing absolute directory first.'
operationId: pickLanePath
requestBody:
content:
application/json:
schema:
type: object
properties:
kind:
type: string
enum:
- dir
- file
default: dir
start:
type: string
description: Folder (or file) to open the dialog at.
responses:
'200':
description: '`{path}` — the chosen absolute path — or `{cancelled: true}`.'
content:
application/json:
schema:
type: object
properties:
path:
type: string
cancelled:
type: boolean
'403':
description: EBADORIGIN (not same-origin) or EREMOTE (not a local browser).
'409':
description: EBUSY — a picker is already open.
'500':
description: EDIALOG — the dialog program failed.
'501':
description: ENODIALOG — no desktop session or no dialog program.
/api/lanes/ensure:
post:
tags:
@@ -11415,6 +11479,38 @@ paths:
description: ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
'404':
description: Lane not found.
/api/lanes/{id}/git/raw:
get:
tags:
- Lanes
summary: One image file's bytes at a revision
description: 'For the viewer''s <img>. Same path, rev and secret guards as /git/file, plus an extension allowlist (png, jpg, jpeg, gif, webp, avif, bmp, ico, svg) that sets the Content-Type, and a 20 MB cap. Served with `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`, so an SVG opened from this URL runs no script. Read-only, no same-origin guard.'
operationId: readLaneImage
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: path
in: query
required: true
schema:
type: string
- name: rev
in: query
schema:
type: string
default: work
responses:
'200':
description: The image bytes, typed by extension.
'400':
description: EBADPATH, EBADREV, ENOFILE, ENOTIMAGE or ETOOBIG.
'403':
description: ESECRET.
'404':
description: Lane not found.
/api/lanes/{id}/git/ls:
get:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.0.1",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.0.1",
"version": "2.1.0",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.0.1",
"version": "2.1.0",
"bin": {
"agenthub": "bin/agenthub.js"
},
+72 -32
View File
@@ -1,41 +1,81 @@
---
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, systemd, tmux, UI build
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, systemd, tmux, UI build — and offer to fix what it finds
---
Run the diagnostic and report what it found. Do not fix anything unless the user
asks.
## 1. Diagnose
Every command here runs from the plugin build that is installed NOW (`ROOT`):
a session not restarted after an update still names the old build in
`${CLAUDE_PLUGIN_ROOT}`, which would warn about itself forever. The shell does
not keep `ROOT` between commands, so each command block below resolves it
again:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-doctor.js"
ROOT=$(node -e 'console.log(require(process.argv[1]).supersededBy() || "")' \
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js")
ROOT="${ROOT:-${CLAUDE_PLUGIN_ROOT}}"
node "$ROOT/scripts/plugin-doctor.js"
```
Print the output verbatim, then add at most three lines of guidance based on the
`FAIL` and `WARN` rows:
Print the output verbatim. If every row is `OK`, stop there — no summary of
things that passed.
- `FAIL Node` → the installed Node is too old for `node:sqlite`; the server
cannot start until Node is upgraded.
- `FAIL Hooks` → duplicate hook entries double every token and cost figure.
Starting a new Claude Code session removes them automatically (the bootstrap
backs the file up as `settings.json.agenthub-bak` first).
- `FAIL Server` → run `/agenthub-update`, then check
`~/.claude/agenthub/runtime/bootstrap.log` and `server.log`.
- `FAIL Runtime deps` → the install is incomplete, not merely absent. Delete
`~/.claude/agenthub/runtime/node_modules` before running `/agenthub-update`;
reinstalling over the broken tree is what let it survive.
- `FAIL npm` or `FAIL Disk` → nothing else in the report can be trusted until
these are fixed; both silently truncate installs.
- `WARN agenthub CLI` → print the exact `export PATH=...` line from the output.
- `WARN systemd` → when it says the unit names a **previous plugin version**,
the dashboard is running now but will NOT come back after a reboot. Starting
a new Claude Code session repairs it; print the `--force` command from the
output as the immediate fix. "installed but not running" just needs
`systemctl --user start agenthub`.
- `WARN tmux` → only the run console, `agenthub lanes shell` and the
session-reload keystrokes are affected; monitoring, workspaces and the CLI are not.
Print the install command from the output. If the row says `ok` but Start Run
still fails, the server has a different PATH than this check — ask it directly
with `curl -s localhost:4820/api/run/tmux`.
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle. When the row
quotes a build error, report that error — it is the actual problem.
## 2. Offer to fix
No preamble, no summary of things that passed.
If any row is `FAIL` or `WARN`, say in one line per row what is wrong (use the
guide below), then ask with `AskUserQuestion`: **"Tự động sửa các lỗi trên?"**
(answer in the user's language) — options "Có, cho tôi xem danh sách việc sẽ
làm" and "Không". On "Không", stop.
## 3. Show the plan, get approval
Build the plan from the table below: only the rows that are not `OK`, in the
table's order, each with the EXACT command you will run. Merge duplicates — one
`plugin-bootstrap.js --force` covers every row marked *bootstrap*. Rows marked
*report only* go in a separate "cannot fix automatically" list. Print the plan
as a numbered list, then ask with `AskUserQuestion`: **"Thực hiện các bước
trên?"** — "Đồng ý" / "Không". Do nothing until the user agrees.
Detect what the commands need before printing them: the OS (`uname -s`), the
package manager (`apt-get`, `dnf`, `pacman`, `brew`), a Node version manager
(`nvm`, `fnm`, `volta` — check with `command -v` or `~/.nvm/nvm.sh`), the
user's shell rc file from `$SHELL`, and whether `sudo -n true` works.
| Row | Fix |
|---|---|
| `FAIL Node` / `FAIL npm` | Install Node ≥ 22.5 (it ships npm). Prefer the user's version manager (`nvm install 22 && nvm alias default 22`, `fnm install 22 && fnm default 22`, `volta install node@22`); else `brew install node` (macOS) or the distro's package (`sudo apt-get install -y nodejs` only when it is ≥ 22.5 — otherwise propose nvm). Then run the *bootstrap* step with THAT node's absolute path, since the `agenthub` CLI launcher pins the node that ran the bootstrap. |
| `FAIL Disk` | *Report only.* Show what is large (`du -sh "<Data dir>"/* 2>/dev/null`, the `Data dir` row's path); never delete user data. |
| `FAIL Runtime deps` | `rm -rf "<Data dir>/runtime/node_modules"` (the `Data dir` row's path), then *bootstrap* — reinstalling over the broken tree is what lets it survive. |
| `WARN Runtime deps`, `WARN Bootstrap`, `FAIL Server`, `FAIL Hooks`, `WARN agenthub CLI` "no launcher", `WARN systemd` "previous plugin version" | *bootstrap* (it reinstalls deps, removes the duplicate hook entries after backing up `settings.json` as `settings.json.agenthub-bak`, rewrites the CLI launcher and the unit, and restarts the server). |
| `WARN agenthub CLI` "not on PATH" | Append the exact `export PATH=...` line from the row to the shell rc file — only if that file does not already contain it. |
| `WARN systemd` "not running" | `systemctl --user start agenthub` (Linux) / `agenthub service install` (macOS LaunchAgent). |
| `WARN systemd` "not written by AgentHub" | *Report only* — it is the user's unit. |
| `WARN tmux` "not found" | `sudo apt-get install -y tmux` / `sudo dnf install -y tmux` / `sudo pacman -S --noconfirm tmux` / `brew install tmux`. |
| `WARN tmux` older than 3.7 | macOS: `brew upgrade tmux`. Linux: *report only* — building tmux from source is the user's call; quote the row's instructions. |
| `WARN tmux` "running tmux server is" | *Report only* — it clears once every tmux session has closed. Never run `tmux kill-server`. |
| `WARN Dashboard UI` | the *bootstrap* step's `ROOT` lines, then `node "$ROOT/scripts/plugin-open.js" --force`, as ONE command — skip it when this doctor run was started by `/agenthub-open` (it re-runs the build itself). |
The *bootstrap* step, run as ONE command so `ROOT` survives (it runs from the
plugin build that is installed now — an older build's bootstrap refuses):
```bash
ROOT=$(node -e 'console.log(require(process.argv[1]).supersededBy() || "")' \
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js")
ROOT="${ROOT:-${CLAUDE_PLUGIN_ROOT}}"
node "$ROOT/scripts/plugin-bootstrap.js" --force
```
## 4. Fix, then verify
Run the approved steps in order, one at a time, and stop at the first one that
fails: quote its decisive error line (for the bootstrap, from
`<Data dir>/runtime/bootstrap.log`) and ask how to continue.
- Run nothing that is not in the approved plan. If a step turns out to need
something else, ask again first.
- A `sudo` command when `sudo -n true` failed cannot be typed from here: ask
the user to run it themselves as `! <command>`, wait, then continue.
- A shell rc change only reaches new terminals — say so.
Finish by running the diagnosis again (step 1's command, `ROOT` lines included) and printing it verbatim, then at most
three lines on what is still not `OK` and why.
+20 -4
View File
@@ -1,14 +1,23 @@
---
description: Rebuild the dashboard UI and print its URL
description: Rebuild the dashboard UI and print its URL — runs the doctor when it fails
---
Rebuild the dashboard bundle, then print the URL. Always forces a rebuild so a
stale bundle (e.g. after a fix commit landed but the bootstrap's build predates
it) never serves silently. Also finishes the build if the bootstrap's own
attempt failed (check `~/.claude/agenthub/runtime/client-build.log`).
attempt failed (check `client-build.log` in the runtime dir — `<data dir>/runtime`,
by default `~/.claude/agenthub/runtime`).
Run it from the plugin build that is installed NOW, as ONE command so `ROOT`
survives — a session not restarted after an update still names the old build
in `${CLAUDE_PLUGIN_ROOT}`, whose bootstrap refuses to run (`superseded`) and
whose client would be built over the new one:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js" --force
ROOT=$(node -e 'console.log(require(process.argv[1]).supersededBy() || "")' \
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js")
ROOT="${ROOT:-${CLAUDE_PLUGIN_ROOT}}"
node "$ROOT/scripts/plugin-open.js" --force
```
Install + build takes a few minutes if the client toolchain isn't already
@@ -19,7 +28,14 @@ re-runs bootstrap first and prints `Server restarted on the current plugin
version.` (without it, the fresh bundle would call routes that old server
never had).
Then help the user open it:
**If it exits non-zero** (a failed build, or a bootstrap that did not bring the
server up): quote its error line, say you are running the doctor, then read
`$ROOT/plugins/agenthub/commands/agenthub-doctor.md` (the `ROOT` resolved above)
and follow it from step 1 — diagnose, offer to fix, show the plan, fix only what
the user approves. After the fixes, run the `plugin-open.js --force` command
above once more; if it fails again, report its error and stop.
On success, help the user open the URL:
```bash
uname -s
+21 -19
View File
@@ -23,29 +23,31 @@ else
fi
```
That downloads the new version into `~/.claude/plugins/cache/` but does not
take effect in *this* session — Claude Code only re-resolves `${CLAUDE_PLUGIN_ROOT}`
on `/reload-plugins` (a REPL-only action, not scriptable from a command). Say
so plainly: **tell the user to run `/reload-plugins` now**, then either
re-run `/agenthub-update` (this time the bootstrap step below runs against the new
version) or just start a new session — `plugin-bootstrap.js` compares the
recorded state against the current plugin version on every `SessionStart` and
rebuilds automatically if they differ.
Then, whether or not a plugin update was just pulled, refresh the runtime
against whatever `${CLAUDE_PLUGIN_ROOT}` currently resolves to. This stops the
running dashboard server before starting the new one. Say so, then run:
That downloads the new version into a NEW directory under
`~/.claude/plugins/cache/`, while `${CLAUDE_PLUGIN_ROOT}` in this session still
names the old one — and the old build's bootstrap refuses to run once a newer
build is installed (`supersededBy`). So refresh the runtime from the build that
is installed now: the new directory when one was just pulled, else this one.
This stops the running dashboard server before starting the new one. Say so,
then run:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js" --force
ROOT=$(node -e 'console.log(require(process.argv[1]).supersededBy() || "")' \
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js")
ROOT="${ROOT:-${CLAUDE_PLUGIN_ROOT}}"
echo "Refreshing the AgentHub runtime from $ROOT"
node "$ROOT/scripts/plugin-bootstrap.js" --force
node "$ROOT/scripts/plugin-doctor.js"
```
It runs in the foreground and can take a few minutes on a cold cache. When it
finishes, verify:
Run it as ONE command (the shell does not keep `ROOT` between commands). The
bootstrap runs in the foreground and can take a few minutes on a cold cache;
the doctor that follows verifies the result.
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-doctor.js"
```
If a new version was pulled, tell the user the dashboard already runs it, but
this Claude Code session still loads the old plugin's commands and skills until
they run `/reload-plugins` or start a new session.
Report the final state in a few lines. If the bootstrap failed, quote the
decisive line from `~/.claude/agenthub/runtime/bootstrap.log`.
decisive line from `bootstrap.log` in the runtime dir (`<Data dir>/runtime` —
the doctor's `Data dir` row; by default `~/.claude/agenthub/runtime`).
+1 -1
View File
@@ -265,7 +265,7 @@ function tmuxStatus() {
return [
"warn",
"tmux",
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
"not found — Start Run and `agenthub workspace shell` will fail (everything else is unaffected). " +
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
];
}
+18 -3
View File
@@ -9,7 +9,9 @@
* for `--force` rebuilds or a first build that failed during bootstrap, and it
* re-runs bootstrap first when the live server belongs to a retired plugin root
* (a mid-session `/plugin` update) so the new bundle is not left calling routes
* the running server never had.
* the running server never had. It exits 1 when that bootstrap does not answer
* `ok` (bar `locked` — another one is running) as well as when the build fails,
* so `/agenthub-open` knows to run the doctor.
*
* The client source is copied out of the (replaced-on-update) plugin cache into
* the runtime dir before building, so neither the cache nor the checkout is
@@ -122,7 +124,20 @@ if (require.main === module) {
// old server never had. Hand the version handover back to bootstrap, which
// owns stop/start and the dependency install the new root may need.
const stale = (boot.readState() || {}).pluginRoot !== PLUGIN_ROOT || !boot.serverIsLive();
if (stale) boot.bootstrap();
// Its answer matters: printing a URL with exit 0 after a refused bootstrap
// (old Node, failed install) told /agenthub-open everything was fine.
const booted = stale ? boot.bootstrap() : "ok";
if (booted === "locked") {
// A SessionStart bootstrap holds the lock and will (re)start the server
// itself; a doctor's `--force` would only hit the same lock.
console.error("Another bootstrap is running — the server restarts when it finishes.");
} else if (booted !== "ok") {
console.error(
`The dashboard server was not (re)started: bootstrap answered "${booted}" — ` +
`see ${path.join(boot.runtimeDir(), "bootstrap.log")}`
);
process.exitCode = 1;
}
const result = buildClient({ force: process.argv.includes("--force") });
if (result === "failed") {
@@ -130,7 +145,7 @@ if (require.main === module) {
process.exitCode = 1;
} else {
if (result === "built") console.log("Dashboard UI built.");
if (stale) console.log("Server restarted on the current plugin version.");
if (stale && booted === "ok") console.log("Server restarted on the current plugin version.");
console.log(dashboardUrl());
}
}
+7
View File
@@ -139,6 +139,13 @@ describe("GET /api/pricing/cost daily split", () => {
"2026-10-05": 3,
});
assert.equal(ict.body.total_cost, 7);
// `date=` prices that one local day, breakdown included.
const day = await call("GET", "/api/pricing/cost?tz_offset=-420&date=2026-10-03");
assert.equal(day.body.total_cost, 2);
assert.equal(day.body.breakdown.length, 1);
assert.equal(day.body.breakdown[0].input_tokens, 2 * M);
assert.equal((await call("GET", "/api/pricing/cost?date=10/03")).status, 400);
});
it("scales hours above the total down to it, so the days still add up", async () => {
+11
View File
@@ -41,6 +41,17 @@ describe("dashboard-runs", () => {
assert.ok(old.ended_at);
});
it("narrows a lane's history to one tool", () => {
start("agenthub-lane-8", { laneId: 8, tool: "claude" });
start("agenthub-lane-8-1", { laneId: 8, tool: "shell" });
const shells = runs.listRuns({ laneId: 8, tool: "shell" });
assert.deepEqual(
shells.map((r) => r.id),
["agenthub-lane-8-1"]
);
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
@@ -15,7 +15,7 @@ const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
const { collectChanges, parseNumstat, readFileAt, readImageAt } = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
@@ -179,6 +179,73 @@ describe("discard of a staged rename", () => {
});
});
describe("readImageAt: an image's bytes for the viewer", () => {
// Every byte value, so a utf8 round-trip anywhere would show.
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
it("reads the working file and a committed one byte for byte", async () => {
write(LANE, "img/logo.PNG", bytes);
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "img");
const work = await readImageAt(LANE, "img/logo.PNG", "work");
assert.equal(work.type, "image/png");
assert.deepEqual(work.buf, bytes);
const head = await readImageAt(LANE, "img/logo.PNG", "HEAD");
assert.deepEqual(head.buf, bytes);
});
it("refuses a non-image, a missing file and a symlink out of the lane", async () => {
await assert.rejects(readImageAt(LANE, "a.txt", "work"), { code: "ENOTIMAGE" });
await assert.rejects(readImageAt(LANE, "none.png", "work"), { code: "ENOFILE" });
fs.writeFileSync(path.join(ROOT, "outside.png"), bytes);
fs.symlinkSync(path.join(ROOT, "outside.png"), path.join(LANE, "leak.png"));
await assert.rejects(readImageAt(LANE, "leak.png", "work"));
await assert.rejects(readImageAt(LANE, "../outside.png", "work"));
});
});
describe("GET /api/lanes/:id/git/raw", () => {
it("serves an image with its type, and an SVG sandboxed", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "raw.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const server = await startServer(createApp(), 0);
const base = `http://127.0.0.1:${server.address().port}`;
try {
write(LANE, "pic.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255]));
write(
LANE,
"x.svg",
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
);
const created = await fetch(`${base}/api/lanes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd: LANE, title: "raw" }),
}).then((r) => r.json());
const id = created.lane.id;
const png = await fetch(`${base}/api/lanes/${id}/git/raw?path=pic.png`);
assert.equal(png.status, 200);
assert.equal(png.headers.get("content-type"), "image/png");
assert.deepEqual(
Buffer.from(await png.arrayBuffer()),
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255])
);
const svg = await fetch(`${base}/api/lanes/${id}/git/raw?path=x.svg`);
assert.equal(svg.headers.get("content-type"), "image/svg+xml");
assert.match(svg.headers.get("content-security-policy"), /sandbox/);
assert.equal(svg.headers.get("x-content-type-options"), "nosniff");
const txt = await fetch(`${base}/api/lanes/${id}/git/raw?path=a.txt`);
assert.equal(txt.status, 400);
assert.equal((await txt.json()).error.code, "ENOTIMAGE");
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
} finally {
server.close();
}
});
});
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
it("answers 403 for the router's settings file", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
+67 -6
View File
@@ -129,6 +129,23 @@ describe("/api/lanes", () => {
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("serves the same routes under /api/workspaces, the UI's word", async () => {
const lanes = await request("GET", "/api/lanes");
const list = await request("GET", "/api/workspaces");
assert.equal(list.status, 200);
assert.deepEqual(
list.body.lanes.map((l) => l.id),
lanes.body.lanes.map((l) => l.id)
);
const one = await request("GET", `/api/workspaces/${laneId}`);
assert.equal(one.status, 200);
assert.equal(one.body.lane.id, laneId);
// Mounted through the sources router, which sits before the lanes router.
const sources = await request("GET", `/api/workspaces/${laneId}/sources`);
assert.equal(sources.status, 200);
assert.deepEqual(sources.body, (await request("GET", `/api/lanes/${laneId}/sources`)).body);
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
@@ -220,6 +237,42 @@ describe("/api/lanes", () => {
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("opens the native picker only for a local, unproxied, same-origin browser", async () => {
const nd = require("../lib/native-dialog");
const desktop = require("../lib/desktop-env");
const calls = [];
nd.__setExecImpl(async (cmd) => {
calls.push(cmd);
return { code: 0, stdout: "/tmp/picked\n", stderr: "" };
});
desktop.__setExecImpl(() => "DISPLAY=:0\n");
try {
for (const headers of [
{ Origin: "https://attacker.example" },
{ "X-Forwarded-For": "203.0.113.9" },
{ Forwarded: "for=203.0.113.9" },
]) {
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }, headers);
assert.equal(r.status, 403, JSON.stringify(headers));
}
assert.deepEqual(calls, []);
if (process.platform === "linux") {
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" });
assert.equal(r.status, 200);
assert.equal(r.body.path, "/tmp/picked");
nd.__setExecImpl(async () => ({ missing: true }));
const none = await request("POST", "/api/lanes/pick-path", { kind: "file" });
assert.equal(none.status, 501);
assert.equal(none.body.error.code, "ENODIALOG");
}
} finally {
nd.__setExecImpl(null);
desktop.__setExecImpl(() => {
throw new Error("no systemd user manager in this test");
});
}
});
it("broadcasts lane_update on create", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
@@ -425,8 +478,13 @@ describe("lane start input", () => {
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-c") + 1], cwd);
// Nothing after `--`: tmux runs the user's own default shell.
assert.equal(created[created.length - 1], "--");
// After `--` only the fixed size-wait wrapper that execs the user's own
// default shell (issue #55) — nothing from the request.
const argv = created.slice(created.indexOf("--") + 1);
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
assert.equal(argv.length, 3);
assert.match(argv[2], /exec "\$SHELL" -l$/);
assert.ok(!created.includes("ignored"));
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.run_id, null);
assert.notEqual(lane.status, "running");
@@ -1958,13 +2016,16 @@ describe("start: slot auto and a session already live", () => {
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
});
it("answers ERUNLIVE when every slot is busy", async () => {
live.add(`agenthub-lane-${id}-3`);
it("answers ERUNLIVE when all ten slots are busy", async () => {
for (let s = 3; s <= 8; s++) live.add(`agenthub-lane-${id}-${s}`);
// Slot 9 is the tenth and last.
const ninth = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
assert.equal(ninth.body.run_id, `agenthub-lane-${id}-9`);
live.add(`agenthub-lane-${id}-9`);
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ERUNLIVE");
live.delete(`agenthub-lane-${id}-3`);
live.delete(`agenthub-lane-${id}-2`);
for (let s = 2; s <= 9; s++) live.delete(`agenthub-lane-${id}-${s}`);
});
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
+30 -1
View File
@@ -193,6 +193,24 @@ after(() => {
}
});
describe("agenthub workspaces", () => {
it("is the lanes command under the UI's word", async () => {
const [lanes, workspaces, workspace] = await Promise.all([
cli(["lanes"], LANE_DIR),
cli(["workspaces"], LANE_DIR),
cli(["workspace"], LANE_DIR),
]);
assert.equal(workspaces.status, 0, workspaces.stderr);
assert.match(workspaces.stdout, /CLI lane/);
assert.equal(workspaces.stdout, lanes.stdout);
assert.equal(workspace.stdout, lanes.stdout);
// The old name still works but says so — on stderr, never in stdout.
assert.match(lanes.stderr, /old name.*agenthub workspace/);
assert.doesNotMatch(workspaces.stderr, /old name/);
assert.doesNotMatch(lanes.stdout, /old name/);
});
});
describe("agenthub stage", () => {
it("reports a stage for the lane owning the current directory", async () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
@@ -280,6 +298,17 @@ describe("agenthub stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(codexLane.id), "implement");
});
it("--workspace names the lane like --lane", async () => {
const r = await cli(
["stage", "review", "--workspace", String(codexLane.id)],
DUAL_DIR,
NO_HARNESS
);
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "review");
assert.notEqual(await stageOf(claudeLane.id), "review");
});
it("fails loudly, naming both candidates, when nothing identifies the tool", async () => {
const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)];
const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS);
@@ -686,7 +715,7 @@ describe("agenthub lanes node", () => {
assert.notEqual(r.status, 0);
assert.match(r.stdout, /\bintake\b/);
assert.match(r.stdout, /\bdone\b/);
assert.match(r.stderr, /usage: agenthub lanes node/);
assert.match(r.stderr, /usage: agenthub workspace node/);
});
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
+105
View File
@@ -0,0 +1,105 @@
/**
* @file native-dialog.test.js
* @description Unit tests for the server-side native folder/file picker: the
* argv each platform gets, the fallback from zenity to kdialog, Cancel versus a
* chosen path, and the "no dialog program" answer the client falls back on.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const os = require("node:os");
const nd = require("../lib/native-dialog");
const desktop = require("../lib/desktop-env");
const linux = process.platform === "linux";
describe("native-dialog", () => {
afterEach(() => nd.__setExecImpl(null));
it("starts in the given folder, a file's folder, or home", () => {
assert.equal(nd.startDir(os.tmpdir()), os.tmpdir());
assert.equal(nd.startDir("/definitely/not/here/x"), os.homedir());
assert.equal(nd.startDir("~"), os.homedir());
assert.equal(nd.startDir(undefined), os.homedir());
});
it("builds zenity then kdialog on Linux, the request's folder one argv element", () => {
const [[z, zargs], [k, kargs]] = nd.dialogCommands("dir", "/tmp/a b", "linux");
assert.equal(z, "zenity");
assert.deepEqual(zargs, [
"--file-selection",
"--title=Select a folder",
"--filename=/tmp/a b/",
"--directory",
]);
assert.equal(k, "kdialog");
assert.deepEqual(kargs.slice(0, 2), ["--getexistingdirectory", "/tmp/a b"]);
assert.ok(!nd.dialogCommands("file", "/tmp", "linux")[0][1].includes("--directory"));
});
it("passes the folder to osascript as an argument, never inside the script", () => {
const [[cmd, args]] = nd.dialogCommands("file", '/x"; do shell script "id', "darwin");
assert.equal(cmd, "osascript");
assert.equal(args.at(-1), '/x"; do shell script "id');
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
});
it(
"answers the chosen path, null on Cancel, and falls through a missing program",
{
skip: !linux,
},
async () => {
desktop.__setExecImpl(() => "DISPLAY=:0\n");
const tried = [];
nd.__setExecImpl(async (cmd) => {
tried.push(cmd);
return cmd === "zenity" ? { missing: true } : { code: 0, stdout: "/home/u/proj\n" };
});
assert.equal(await nd.pickPath("dir"), "/home/u/proj");
assert.deepEqual(tried, ["zenity", "kdialog"]);
nd.__setExecImpl(async () => ({ code: 1, stdout: "" }));
assert.equal(await nd.pickPath("file"), null);
nd.__setExecImpl(async () => ({ missing: true }));
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
}
);
it(
"answers ENODIALOG, not a Cancel, when the program could not reach the desktop",
{ skip: !linux },
async () => {
desktop.__setExecImpl(() => "DISPLAY=:0\n");
nd.__setExecImpl(async () => ({
code: 1,
stdout: "",
stderr: "(zenity:1): Gtk-WARNING **: cannot open display: :0\n",
}));
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
}
);
it("passes the abort signal through to the dialog program", { skip: !linux }, async () => {
desktop.__setExecImpl(() => "DISPLAY=:0\n");
const ac = new AbortController();
let seen;
nd.__setExecImpl(async (cmd, args, env, signal) => {
seen = signal;
return { code: 1, stdout: "", stderr: "" };
});
assert.equal(await nd.pickPath("dir", undefined, ac.signal), null);
assert.equal(seen, ac.signal);
});
it("tells a Cancel from a failure per program", () => {
assert.equal(nd.isCancel("zenity", ""), true);
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true);
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false);
assert.equal(nd.isCancel("powershell.exe", ""), true);
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
});
});
+1 -1
View File
@@ -47,7 +47,7 @@ describe("plugin-doctor: tmux row", () => {
const row = rowWithTmux(null);
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /workspace shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /install/i, "and how to fix it");
});
+3 -1
View File
@@ -56,7 +56,9 @@ describe("pty-attach", () => {
it("accepts a well-formed runId", () => {
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-9"));
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-10"), /EBADRUNID/);
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/);
});
it("turns tmux mouse mode on for the session it attaches to", () => {
+46 -2
View File
@@ -58,7 +58,49 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
it("spawnShell starts the login shell only once the pane has its real size (issue #55)", async () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
pty.spawnShell({ laneId: 43, cwd: "/tmp/repo" });
const call = calls.find((c) => c[0] === "new-session");
const argv = call.slice(call.indexOf("--") + 1);
assert.equal(argv[0], "sh");
assert.equal(argv[1], "-c");
// Run the wrapper with a stand-in shell and a fake `tmux` that reports no
// client until a flag file appears: the shell must wait for the attach,
// then start at once rather than at the 2 s cap.
const { spawn } = require("node:child_process");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
const flag = path.join(bin, "attached");
fs.writeFileSync(
path.join(bin, "tmux"),
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
{ mode: 0o755 }
);
try {
const t0 = Date.now();
const child = spawn("sh", ["-c", argv[2]], {
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
});
let out = "";
let startedAt = 0;
child.stdout.on("data", (d) => {
out += d;
startedAt ||= Date.now() - t0;
});
setTimeout(() => fs.writeFileSync(flag, ""), 400);
await new Promise((r) => child.on("exit", r));
assert.equal(out.trim(), "-l");
assert.ok(startedAt >= 400 && startedAt < 1500, `shell started after ${startedAt} ms`);
} finally {
fs.rmSync(bin, { recursive: true, force: true });
}
});
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 9 (ten per workspace)", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("no such session");
@@ -72,8 +114,10 @@ describe("pty-run", () => {
assert.equal(handle.laneId, 42);
assert.equal(handle.slot, 2);
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
assert.equal(pty.spawnRun({ laneId: 42, slot: 9, cwd: "/tmp/repo" }).id, "agenthub-lane-42-9");
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-9"), 42);
assert.throws(
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
() => pty.spawnRun({ laneId: 42, slot: 10, cwd: "/tmp/repo" }),
(err) => err.code === "EBADSLOT"
);
});
+7 -3
View File
@@ -130,9 +130,13 @@ function createApp() {
app.use("/api/git-credentials", gitCredentialsRouter);
app.use("/api/ai-tools", aiToolsRouter);
app.use("/api/run", runRouter);
// Before the lanes router, whose `/:id/:action` would otherwise see these.
app.use("/api/lanes", laneSourcesRouter);
app.use("/api/lanes", lanesRouter);
// `/api/workspaces` is the same router under the UI's word; `/api/lanes`
// stays for every client and script that already names it (whether it ever
// goes is step 4c of docs/plans/workspace-rename/README.md, default keep).
// Sources first: the lanes router's `/:id/:action` would otherwise see them.
const LANE_MOUNTS = ["/api/lanes", "/api/workspaces"];
app.use(LANE_MOUNTS, laneSourcesRouter);
app.use(LANE_MOUNTS, lanesRouter);
app.use("/api/projects", projectsRouter);
app.use("/api/locks", locksRouter);
app.use("/api/pets", petsRouter);
+12 -2
View File
@@ -68,6 +68,14 @@ const listByLaneStmt = db.prepare(`
LIMIT @limit
`);
const listByLaneToolStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs
WHERE lane_id = @laneId AND tool = @tool
ORDER BY started_at DESC
LIMIT @limit
`);
const getStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
FROM dashboard_runs WHERE id = @id
@@ -144,10 +152,12 @@ function patchRun({ id, sessionId, status, exitCode, endedAt }) {
}
}
/** @param {{limit?: number, laneId?: number|null}} [opts] laneId narrows to one lane's runs. */
function listRuns({ limit = 50, laneId = null } = {}) {
/** @param {{limit?: number, laneId?: number|null, tool?: string|null}} [opts]
* laneId narrows to one lane's runs, and tool (with a laneId) to one tool's. */
function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
try {
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
if (laneId != null && tool) return listByLaneToolStmt.all({ limit: safeLimit, laneId, tool });
if (laneId != null) return listByLaneStmt.all({ limit: safeLimit, laneId });
return listStmt.all({ limit: safeLimit });
} catch {
+55
View File
@@ -723,6 +723,60 @@ async function readFileAt(dir, requested, rev = "work") {
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
}
/** The image types the viewer shows, by extension. Raster and SVG only. */
const IMAGE_TYPES = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
};
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
/**
* One image's bytes at a revision, for the viewer's `<img>`: `readFileAt`'s
* guards (lexical path, safe rev, secret, realpath-in-lane for `work`), plus
* an extension allowlist that also picks the Content-Type and a size cap.
*/
async function readImageAt(dir, requested, rev = "work") {
const file = lanePath(requested);
const type = IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
if (!type) throw badRequest("ENOTIMAGE", "not an image file");
if (rev !== "work" && !SAFE_REV.test(rev)) {
throw badRequest("EBADREV", "rev is not a valid revision");
}
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
const tooBig = () => badRequest("ETOOBIG", "image is larger than 20 MB");
if (rev === "work") {
const abs = await resolveInLane(dir, file);
let stat;
try {
stat = await fs.promises.stat(abs);
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
return { type, buf: await fs.promises.readFile(abs) };
}
try {
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
encoding: "buffer",
maxBuffer: MAX_IMAGE_BYTES,
});
return { type, buf: stdout };
} catch (err) {
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw tooBig();
throw badRequest("ENOFILE", "no such file at that revision");
}
}
/** The version stamp of a file's bytes, for a stale-save check. */
function contentSha(buf) {
return crypto.createHash("sha1").update(buf).digest("hex");
@@ -983,6 +1037,7 @@ module.exports = {
parsePorcelainZ,
parseUnifiedDiff,
readFileAt,
readImageAt,
resolveBaseRef,
DEFAULT_CONTEXT,
MAX_CONTEXT,
+180
View File
@@ -0,0 +1,180 @@
/**
* @file native-dialog.js
* @description Opens the operating system's own folder/file picker ON THE
* SERVER'S DESKTOP and returns the absolute path the user chose. A browser's
* native picker never hands a page an absolute path, and the dashboard is
* local-first, so the server asks the desktop itself: zenity or kdialog on
* Linux (with the graphical session's variables from `desktop-env.js`, which a
* systemd user service lacks), `osascript` on macOS, PowerShell's WinForms
* dialogs on Windows. Runs with `execFile` (never a shell, never sync — a
* dialog waits on a human for minutes), one dialog at a time. Nothing from the
* request reaches argv except the starting folder, resolved to an absolute
* path first. macOS and Windows are code-only, untested on those systems.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { desktopEnv } = require("./desktop-env");
const TITLES = { dir: "Select a folder", file: "Select a file" };
const TIMEOUT_MS = 10 * 60 * 1000;
const defaultExec = (cmd, args, env, signal) =>
new Promise((resolve) => {
execFile(
cmd,
args,
{ env, timeout: TIMEOUT_MS, windowsHide: true, encoding: "utf8", signal },
(err, stdout, stderr) =>
resolve({
missing: err?.code === "ENOENT",
code: err ? (typeof err.code === "number" ? err.code : -1) : 0,
stdout: String(stdout || ""),
stderr: String(stderr || ""),
})
);
});
let execImpl = defaultExec;
/** Test seam — same pattern as desktop-env.js; `null` restores the real one. */
function __setExecImpl(fn) {
execImpl = fn || defaultExec;
}
/** An existing folder to open the dialog in: `start` (or its parent when it is a file), else home. */
function startDir(start) {
if (typeof start === "string" && start.trim()) {
const raw = start.trim().replace(/^~(?=$|[\\/])/, os.homedir());
const abs = path.resolve(raw);
for (const p of [abs, path.dirname(abs)]) {
try {
if (fs.statSync(p).isDirectory()) return p;
} catch {
// missing — try the parent, then home
}
}
}
return os.homedir();
}
/** The candidate dialog commands for a platform, in order of preference. */
function dialogCommands(kind, dir, platform = process.platform) {
const title = TITLES[kind];
if (platform === "darwin") {
const what = kind === "dir" ? "folder" : "file";
return [
[
"osascript",
[
"-e",
"on run argv",
"-e",
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
"-e",
"end run",
title,
dir,
],
],
];
}
if (platform === "win32") {
const dialog =
kind === "dir"
? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE"
: "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE";
const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName";
return [
[
"powershell.exe",
[
"-NoProfile",
"-STA",
"-Command",
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ${dialog}; if ($d.ShowDialog() -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
],
],
];
}
const sep = dir.endsWith("/") ? "" : "/";
return [
[
"zenity",
[
"--file-selection",
`--title=${title}`,
`--filename=${dir}${sep}`,
...(kind === "dir" ? ["--directory"] : []),
],
],
[
"kdialog",
[kind === "dir" ? "--getexistingdirectory" : "--getopenfilename", dir, "--title", title],
],
];
}
// A dialog that could not reach the desktop exits 1 too — a stale DISPLAY
// handed over by `systemctl --user show-environment` after a logout, say.
const NO_DISPLAY_RE =
/cannot open display|could not connect to display|failed to open display|cannot connect to x server|unable to init server/i;
/**
* Whether an exit status of 1 means the user pressed Cancel. zenity/kdialog
* may print GTK/Qt warnings on a normal Cancel, so only a display failure
* counts against it; osascript says Cancel as error -128 and nothing else.
*/
function isCancel(cmd, stderr = "") {
if (cmd === "osascript") return /-128/.test(stderr);
if (cmd === "powershell.exe") return !stderr.trim();
return !NO_DISPLAY_RE.test(stderr);
}
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
let busy = false;
/**
* Show the native picker and wait for the user.
* @param {"dir"|"file"} kind
* @param {string} [start] Folder (or file) to open the dialog at.
* @param {AbortSignal} [signal] Closes the dialog — the browser went away.
* @returns {Promise<string|null>} The chosen absolute path, or null when cancelled.
* @throws `ENODIALOG` (no desktop or no dialog program), `EBUSY` (one is already open), `EDIALOG`.
*/
async function pickPath(kind, start, signal) {
if (busy) throw fail("EBUSY", "a picker is already open on the desktop");
const env = { ...process.env, ...desktopEnv() };
if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
throw fail("ENODIALOG", "no graphical desktop session to show a picker on");
}
const dir = startDir(start);
env.AGENTHUB_PICK_START = dir;
env.AGENTHUB_PICK_TITLE = TITLES[kind];
busy = true;
try {
for (const [cmd, args] of dialogCommands(kind, dir)) {
const r = await execImpl(cmd, args, env, signal);
if (r.missing) continue;
if (r.code === 1) {
if (isCancel(cmd, r.stderr)) return null; // every one of these exits 1 on Cancel
throw fail("ENODIALOG", `${cmd} could not open a picker on the desktop`);
}
const out = r.stdout.replace(/[\r\n]+$/, "");
if (r.code !== 0 || !out) throw fail("EDIALOG", `${cmd} exited with ${r.code}`);
// osascript ends a folder with "/"; "/" itself stays.
return out.replace(/\/+$/, "") || "/";
}
throw fail("ENODIALOG", "no native picker found (install zenity or kdialog)");
} finally {
busy = false;
}
}
module.exports = { pickPath, dialogCommands, startDir, isCancel, __setExecImpl };
-1
View File
@@ -32,7 +32,6 @@ const FEATURED_HOST = "https://codex-pets.net";
/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */
const FEATURED = [
{ id: "goku", displayName: "Goku", by: "hackminit" },
{ id: "bac-ho", displayName: "Bac Ho", by: "k0nkupa" },
{ id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" },
{ id: "11", displayName: "11", by: "ct" },
];
+2 -2
View File
@@ -14,10 +14,10 @@
const fs = require("fs");
const tmux = require("./tmux");
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-9])?$/;
/**
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-3>]` before it can ever
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-9>]` before it can ever
* reach a tmux/PTY command — the trust boundary for this WS path, since a
* validated runId is the only thing standing between an authenticated WS
* client and naming an arbitrary session on the host.
+18 -6
View File
@@ -22,8 +22,8 @@
* Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on
* the tool parameter.
*
* A lane can hold up to four sessions, one per Workspace tab: slot 0 is
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–3 are
* A lane can hold up to ten sessions (`MAX_PANE_SLOT`): slot 0 is
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–9 are
* `agenthub-lane-<laneId>-<slot>`. A slot holds an AI tool (`spawnRun`) or a
* plain shell (`spawnShell`, a Terminal tab).
*
@@ -43,9 +43,10 @@ try {
/* db-less environment, skip persistence */
}
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
const MAX_PANE_SLOT = 3;
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/;
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
* Workspace groups them into tabs of up to four split panes each). */
const MAX_PANE_SLOT = 9;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
// `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
@@ -529,7 +530,18 @@ function spawnShell({ laneId, slot = 0, cwd }) {
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
};
tmux.newSession({ name: id, cwd, argv: [], env });
// The session is born 80×24 and only takes the browser's size when the pane
// attaches. A shell started at once printed its prompt at 80 columns (a long
// cwd wrapped), and readline's redraw after the resize left the cursor on top
// of the prompt (issue #55). So wait until a client is attached — 2 s at
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
// resizes the pane before any client is there. A tmux that cannot answer
// ends the wait at once, i.e. the old behaviour.
const waitForSize =
"i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" +
' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l';
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
dashboardRuns?.recordRun({
id,
sessionId: null,
+9 -1
View File
@@ -33,7 +33,14 @@ const STATUS_FILE_CAP = 20;
async function git(
cwd,
args,
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
{
timeout = 0,
maxBuffer = 8 * 1024 * 1024,
literal = false,
env: extraEnv = {},
// "buffer" for a binary read (an image at a revision).
encoding = "utf8",
} = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
@@ -79,6 +86,7 @@ async function git(
// passes one so an unreachable remote fails instead of pinning a request
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
timeout,
encoding,
});
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
+61 -1
View File
@@ -12,7 +12,8 @@
const tags = [
{
name: "Lanes",
description: "Durable parallel-work lanes and dashboard-managed git worktrees",
description:
"Durable parallel-work workspaces (called lanes in the API) and dashboard-managed git worktrees. Every /api/lanes route is also mounted at /api/workspaces — the same handlers, the user-facing name.",
},
];
@@ -111,6 +112,45 @@ const schemas = {
};
const paths = {
"/api/lanes/pick-path": {
post: {
tags: ["Lanes"],
summary: "Open the OS's own folder/file picker on the server's desktop",
description:
"Shows zenity/kdialog (Linux), osascript (macOS) or a WinForms dialog (Windows) on the SERVER's desktop and waits for the user (up to 10 minutes; closed when the request goes away). Only for a browser on the same machine: a non-loopback client, or a request carrying X-Forwarded-For/Forwarded, gets EREMOTE and should fall back to GET /api/lanes/browse. One dialog at a time. Nothing from the request reaches argv but the starting folder, resolved to an existing absolute directory first.",
operationId: "pickLanePath",
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
kind: { type: "string", enum: ["dir", "file"], default: "dir" },
start: { type: "string", description: "Folder (or file) to open the dialog at." },
},
},
},
},
},
responses: {
200: {
description: "`{path}` — the chosen absolute path — or `{cancelled: true}`.",
content: {
"application/json": {
schema: {
type: "object",
properties: { path: { type: "string" }, cancelled: { type: "boolean" } },
},
},
},
},
403: { description: "EBADORIGIN (not same-origin) or EREMOTE (not a local browser)." },
409: { description: "EBUSY — a picker is already open." },
500: { description: "EDIALOG — the dialog program failed." },
501: { description: "ENODIALOG — no desktop session or no dialog program." },
},
},
},
"/api/lanes/ensure": {
post: {
tags: ["Lanes"],
@@ -551,6 +591,26 @@ const paths = {
},
},
},
"/api/lanes/{id}/git/raw": {
get: {
tags: ["Lanes"],
summary: "One image file's bytes at a revision",
description:
"For the viewer's <img>. Same path, rev and secret guards as /git/file, plus an extension allowlist (png, jpg, jpeg, gif, webp, avif, bmp, ico, svg) that sets the Content-Type, and a 20 MB cap. Served with `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`, so an SVG opened from this URL runs no script. Read-only, no same-origin guard.",
operationId: "readLaneImage",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "rev", in: "query", schema: { type: "string", default: "work" } },
],
responses: {
200: { description: "The image bytes, typed by extension." },
400: { description: "EBADPATH, EBADREV, ENOFILE, ENOTIMAGE or ETOOBIG." },
403: { description: "ESECRET." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/ls": {
get: {
tags: ["Lanes"],
@@ -335,6 +335,27 @@ module.exports = {
"for all sources. Narrows the aggregate cost to the given origins.",
example: "local",
},
{
name: "lane",
in: "query",
required: false,
schema: { type: "integer" },
description:
"Only the sessions this workspace owns — the same rule as " +
"GET /api/sessions?lane=. 400 INVALID_INPUT when not an id.",
example: 5,
},
{
name: "date",
in: "query",
required: false,
schema: { type: "string", format: "date" },
description:
"Only that local day (YYYY-MM-DD, in `tz_offset`'s timezone): the " +
"total and breakdown count the tokens spent that day. 400 " +
"INVALID_INPUT for any other shape.",
example: "2026-10-06",
},
],
responses: {
200: {
+9
View File
@@ -812,6 +812,15 @@ const paths = {
"Return only the runs started through this lane (POST /api/lanes/{id}/start). Omit for every run; runs spawned from POST /api/run carry no lane and are excluded whenever this is set.",
example: 5,
},
{
name: "tool",
in: "query",
required: false,
schema: { type: "string" },
description:
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).",
example: "shell",
},
],
responses: {
200: {
+1 -1
View File
@@ -1318,7 +1318,7 @@ const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
*/
function bindRunSession(runId, sessionId, hookType, source, tool) {
if (typeof sessionId !== "string" || !sessionId) return;
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) {
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-9])?$/.test(runId)) {
// No pane named (a hook handler that predates AGENTHUB_RUN_ID): a run
// that was started holding this very session id still owns it.
const laneId = dashboardRuns.laneForSession(sessionId);
+70
View File
@@ -30,6 +30,7 @@ const runs = require("../lib/pty-run");
const { resumeRouter } = require("../lib/router-apply");
const { sameOriginGuard } = require("./run");
const { writeLaneFile } = require("../lib/lane-file-write");
const { pickPath } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight");
const {
LANES_ROOT,
@@ -51,6 +52,7 @@ const {
listTree,
listTreeAll,
readFileAt,
readImageAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { withLaneLock } = require("../lib/lane-lock");
@@ -501,6 +503,40 @@ router.get("/browse", (req, res) => {
res.json({ path: resolved, parent, entries });
});
/**
* The OS's own folder/file picker, shown on the SERVER's desktop
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
* Only a client on this machine may ask — anywhere else the dialog would pop
* up on a screen nobody is looking at (`EREMOTE`); that client, and a server
* with no desktop (`ENODIALOG`), falls back to `GET /browse` above. A request
* a reverse proxy forwarded arrives from loopback too, so a forwarding header
* counts as remote. An SSH tunnel carries no such header and cannot be told
* apart — the dialog is closed when the browser's request goes away.
*/
router.post("/pick-path", sameOriginGuard, async (req, res) => {
const addr = req.socket.remoteAddress || "";
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
return res.status(403).json({
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
});
}
const kind = req.body?.kind === "file" ? "file" : "dir";
const ac = new AbortController();
res.on("close", () => {
if (!res.writableFinished) ac.abort();
});
try {
const picked = await pickPath(kind, req.body?.start, ac.signal);
if (ac.signal.aborted) return;
res.json(picked ? { path: picked } : { cancelled: true });
} catch (err) {
if (ac.signal.aborted) return;
const status = err.code === "EBUSY" ? 409 : err.code === "ENODIALOG" ? 501 : 500;
res.status(status).json({ error: { code: err.code || "EDIALOG", message: err.message } });
}
});
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
@@ -901,6 +937,40 @@ router.get("/:id/git/file", async (req, res) => {
}
});
/**
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
* that picks the Content-Type). Served under a `sandbox` CSP with `nosniff`,
* so an SVG opened straight from this URL runs no script on the dashboard's
* origin.
*/
router.get("/:id/git/raw", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
const { type, buf } = await readImageAt(
t.cwd,
typeof req.query.path === "string" ? req.query.path : "",
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
);
res.set({
"Content-Type": type,
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
});
res.send(buf);
} catch (err) {
if (err.code === "ESECRET") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: "EREAD", message: "could not read the image" } });
}
});
/**
* Save one text file in the lane from the viewer's editor. Guards live in
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
+9 -1
View File
@@ -477,6 +477,12 @@ router.get("/cost", (req, res) => {
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
}
const date = req.query.date;
if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } });
}
const scope = sourceColumnClause(parseSources(req), "s.source");
const params = [...scope.params];
const where = scope.clause ? [scope.clause] : [];
@@ -489,7 +495,9 @@ router.get("/cost", (req, res) => {
}
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
let dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
// `date=`: only that local day (the status bar's Today and its per-model list).
if (date !== undefined) dailyTokens = dailyTokens.filter((r) => r.date === date);
const rules = stmts.listPricing.all();
// Price the date-split rows so each day's usage bills at the rate effective on
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
+3 -1
View File
@@ -84,7 +84,8 @@ router.get("/", (_req, res) => {
*
* Optional ?limit=<n> caps results (default 50, max 500). The most recent
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
* lane, which is what the Workspace page's per-lane history shows.
* lane, and ?tool=<id> (with a laneId) to one tool's — the sidebar reads a
* workspace's closed Terminal tabs with `tool=shell`.
*/
// ── Drag & drop into the terminal ─────────────────────────────────────────
// A local terminal answers a dropped image by typing its PATH, which is how
@@ -149,6 +150,7 @@ router.get("/history", (req, res) => {
const items = dr.listRuns({
limit: Number.isFinite(limit) ? limit : 50,
laneId: Number.isFinite(laneId) ? laneId : null,
tool: typeof req.query.tool === "string" ? req.query.tool : null,
});
const liveIds = new Set(runs.listRuns().map((h) => h.id));
res.json({
+1 -1
View File
@@ -80,7 +80,7 @@ function initWebSocket(server) {
}
let ptyWss = null;
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-3])?)$/;
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-9])?)$/;
/**
* The attaching terminal's own size, off the attach URL's query. tmux resizes
-23
View File
@@ -1,23 +0,0 @@
---
name: debug-live-issue
description: Debug production-like issues in this repository with disciplined evidence gathering. Use when fixing failing workflows, regressions, flaky behavior, or data inconsistencies across hooks, API, DB, websocket, and UI.
---
# Debug Live Issue
Use this workflow for incident-style debugging.
## Steps
- Capture symptom, expected behavior, and reproducible path.
- Isolate subsystem first: hook ingestion, API route, DB state, websocket, or UI rendering.
- Reproduce with minimal surface area.
- Prove root cause before changing code.
- Apply minimal fix and re-verify.
## Evidence standards
- Prefer direct logs, API responses, DB state checks, and deterministic repro steps.
- Avoid speculative fixes without root-cause evidence.
- If not fully reproducible, state uncertainty and strongest hypothesis.
## References
- Investigation template: `references/investigation-template.md`

Some files were not shown because too many files have changed in this diff Show More