Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 837da6e5de | |||
| 7ca1134c3d | |||
| daf5b7be92 | |||
| f0500dc592 | |||
| 6c605849d8 |
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
+4
-4
@@ -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-
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
|
||||
Generated
+112
@@ -8,6 +8,10 @@
|
||||
"name": "agenthub-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
@@ -410,6 +414,66 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"style-mod": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.13",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
|
||||
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
@@ -1052,6 +1116,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2580,6 +2674,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -5702,6 +5802,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -6402,6 +6508,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
|
||||
@@ -70,6 +70,7 @@ import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
import { eventBus } from "./lib/eventBus";
|
||||
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||
import type { WSMessage } from "./lib/types";
|
||||
|
||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||
@@ -128,6 +129,12 @@ export default function App() {
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}, []);
|
||||
|
||||
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
return () => window.removeEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
||||
* with Settings → Pet.
|
||||
* with Settings → Appearance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ export function Sagi() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/pet");
|
||||
navigate("/settings/appearance#pet");
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
|
||||
@@ -159,7 +159,7 @@ describe("PetMenu", () => {
|
||||
fireEvent.click(screen.getByText("Hide pet"));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// Says where to bring it back.
|
||||
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
|
||||
expect(toasts.some((x) => /Settings → Appearance/.test(x.message))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`):
|
||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
|
||||
|
||||
@@ -396,13 +396,15 @@ export function Sidebar({
|
||||
onUpdateAvailableChange?.(updateAvailable);
|
||||
}, [updateAvailable, onUpdateAvailableChange]);
|
||||
|
||||
// Same density and selection as a workspace row, so the whole sidebar
|
||||
// reads as one list: no border, the active item tinted with an accent icon.
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
||||
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -451,7 +453,7 @@ export function Sidebar({
|
||||
Chevron buttons appear at the edges when content is clipped, so the
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 space-y-1">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
return (
|
||||
@@ -475,7 +477,7 @@ export function Sidebar({
|
||||
onClick={onToggle}
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FolderTree className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -528,8 +530,8 @@ export function Sidebar({
|
||||
data-testid="sidebar-toggle"
|
||||
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
{collapsed ? (
|
||||
|
||||
@@ -2,34 +2,325 @@
|
||||
* @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
|
||||
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
|
||||
* whenever the URL names another workspace or session. Each figure carries its
|
||||
* token total, and hovering it lists tokens and cost per model. At the right
|
||||
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
|
||||
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import type { CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
const QUOTA_POLL_MS = 5 * 60_000;
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-run `load` every `ms` and whenever the tab becomes visible again. */
|
||||
function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) {
|
||||
useEffect(() => {
|
||||
const tick = () => document.visibilityState === "visible" && load();
|
||||
tick();
|
||||
const id = setInterval(tick, ms);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0));
|
||||
const quotaTone = (p: number) =>
|
||||
p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : "";
|
||||
const barTone = (p: number) =>
|
||||
p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted";
|
||||
|
||||
function untilText(iso: string | null): string | null {
|
||||
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||
if (!(ms > 0)) return null;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const d = Math.floor(m / 1440);
|
||||
const h = Math.floor((m % 1440) / 60);
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** "account · sign-in state · as of", whichever parts the entry has. */
|
||||
function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) => string) {
|
||||
const until = untilText(q.expiresAt ?? null);
|
||||
return [
|
||||
q.label && q.account,
|
||||
q.expiresAt &&
|
||||
q.error !== "ELIVEEXPIRED" &&
|
||||
(until
|
||||
? t("statusBar.quotaSignInExpires", { time: until })
|
||||
: t("statusBar.quotaSignInExpired")),
|
||||
q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
const { t } = useTranslation("nav");
|
||||
const meta = quotaMeta(q, t);
|
||||
return (
|
||||
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||
{many && q.active !== false && (
|
||||
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||
{t("statusBar.quotaActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
|
||||
{q.error && !q.windows.length ? (
|
||||
<div className="text-status-warning">
|
||||
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={w.label}>
|
||||
<span className="text-fg-muted">
|
||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||
</span>
|
||||
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${barTone(p)}`}
|
||||
style={{ width: `${Math.min(p, 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
||||
{resets && (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
<span aria-hidden>{resets}</span>
|
||||
<span className="sr-only">
|
||||
{t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaPanel({
|
||||
tools,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
}: {
|
||||
tools: ToolQuota[];
|
||||
/** The footer's Refresh: re-reads the quota, as a click on the chips does. */
|
||||
onRefresh: () => void;
|
||||
/** A forced read is in flight: the Refresh icon spins. */
|
||||
refreshing: boolean;
|
||||
/** The pointer moved onto the panel / off it: it stays open while read. */
|
||||
onEnter: () => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("nav");
|
||||
const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][])
|
||||
.map((id) => [id, tools.filter((q) => q.id === id)] as const)
|
||||
.filter(([, list]) => list.length);
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t("statusBar.quotaTitle")}
|
||||
data-testid="status-bar-quota-panel"
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onLeave}
|
||||
onFocus={onEnter}
|
||||
onBlur={onLeave}
|
||||
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
>
|
||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||
{groups.map(([id, list]) => (
|
||||
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
|
||||
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
|
||||
</div>
|
||||
<div className="space-y-2 pl-[1.375rem]">
|
||||
{list.map((q, i) => (
|
||||
<QuotaAccount
|
||||
key={`${q.label ?? q.account ?? ""}-${i}`}
|
||||
q={q}
|
||||
many={list.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="status-bar-quota-refresh"
|
||||
// Not `disabled`: a focused button that turns disabled loses focus, and
|
||||
// that blur would close the panel under the pointer.
|
||||
onClick={() => !refreshing && onRefresh()}
|
||||
aria-busy={refreshing}
|
||||
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
|
||||
refreshing ? "cursor-default" : "hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
|
||||
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||
function QuotaChips() {
|
||||
const { t } = useTranslation("nav");
|
||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
};
|
||||
const hide = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||
};
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const load = useCallback((force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined)
|
||||
.finally(() => force && setRefreshing(false));
|
||||
}, []);
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="status-bar-quota"
|
||||
data-tip-off
|
||||
onClick={() => load(true)}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
|
||||
>
|
||||
{tools
|
||||
.filter((q) => q.active !== false)
|
||||
.map((q) => {
|
||||
const p = Math.round(fullest(q));
|
||||
return (
|
||||
<span key={q.id} className="flex items-center gap-1">
|
||||
<AiToolIcon
|
||||
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
|
||||
className="h-4 w-4 !p-0"
|
||||
/>
|
||||
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
|
||||
{q.error ? (
|
||||
<span className="text-status-warning">!</span>
|
||||
) : (
|
||||
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</button>
|
||||
{open && (
|
||||
<QuotaPanel
|
||||
tools={tools}
|
||||
onEnter={show}
|
||||
onLeave={hide}
|
||||
onRefresh={() => load(true)}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
wsConnected,
|
||||
left,
|
||||
@@ -44,12 +335,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 +354,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 +378,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 +403,6 @@ export function StatusBar({
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [lane, sessionId]);
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<footer
|
||||
@@ -144,20 +450,27 @@ 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}
|
||||
/>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
||||
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
|
||||
* a dismissed one stays dismissed. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
* a dismissed one stays dismissed. The connection modal's "Check for updates"
|
||||
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
|
||||
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -184,7 +185,11 @@ export function UpdateNotifier() {
|
||||
}, [syncFromPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setDismissedSha(null);
|
||||
// An explicit "Check for updates" undoes both kinds of close.
|
||||
const handler = () => {
|
||||
setDismissedSha(null);
|
||||
setClosedKey(null);
|
||||
};
|
||||
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||
}, []);
|
||||
@@ -313,7 +318,7 @@ export function UpdateNotifier() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-notifier-title"
|
||||
|
||||
@@ -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,17 +2,20 @@
|
||||
* @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; the right-end quota chips
|
||||
* (fullest window per tool) and their hover panel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } 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,25 +24,87 @@ 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: [],
|
||||
})),
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
id: "claude",
|
||||
active: true,
|
||||
label: "Work",
|
||||
account: "me@example.com",
|
||||
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 95,
|
||||
resetsAt: new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(),
|
||||
},
|
||||
{ label: "wk", usedPercent: 32, resetsAt: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
active: false,
|
||||
label: "Personal",
|
||||
account: "home@example.com",
|
||||
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||
windows: [],
|
||||
error: "EPROFILEEXPIRED",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
account: "dev@example.com",
|
||||
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||
windows: [],
|
||||
error: "EUPSTREAM",
|
||||
},
|
||||
],
|
||||
checkedAt: "",
|
||||
}),
|
||||
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
|
||||
total_cost: id === "s-live" ? 4.5 : 7,
|
||||
breakdown: [],
|
||||
@@ -63,7 +128,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 +150,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 +160,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 +170,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 +179,54 @@ describe("StatusBar", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Session $4.50")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows each tool's fullest quota window and the details on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chips = await screen.findByTestId("status-bar-quota");
|
||||
expect(chips).toHaveTextContent("Claude95% used");
|
||||
expect(chips.textContent?.match(/Claude/g)).toHaveLength(1);
|
||||
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
||||
await user.hover(chips);
|
||||
const panel = screen.getByTestId("status-bar-quota-panel");
|
||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workin use");
|
||||
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||
expect(work).toHaveTextContent("5h95%41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
expect(personal).not.toHaveTextContent("in use");
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
expect(panel).toHaveTextContent("Could not read the quota");
|
||||
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
||||
await user.unhover(chips);
|
||||
await user.hover(panel);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||
// Only the footer button refreshes: it spins and says so until the read lands.
|
||||
const same = await api.pricing.quota(false);
|
||||
let answer!: (v: unknown) => void;
|
||||
vi.mocked(api.pricing.quota)
|
||||
.mockClear()
|
||||
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never);
|
||||
const refresh = screen.getByTestId("status-bar-quota-refresh");
|
||||
await user.click(refresh);
|
||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||
expect(refresh).toHaveTextContent("Refreshing…");
|
||||
expect(refresh).toHaveAttribute("aria-busy", "true");
|
||||
await user.click(refresh);
|
||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||
await act(async () => answer(same));
|
||||
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
|
||||
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||
await user.unhover(panel);
|
||||
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
||||
vi.mocked(api.pricing.quota).mockClear();
|
||||
await user.click(chips);
|
||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,6 +238,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
|
||||
});
|
||||
|
||||
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
@@ -81,6 +81,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
kill: vi.fn(async () => ({ ok: true })),
|
||||
history: vi.fn(async () => ({ items: [] })),
|
||||
forgetHistory: vi.fn(async () => ({ removed: "" })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
@@ -307,7 +309,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 +343,119 @@ 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("removes a closed terminal from its hover X and its right-click menu", async () => {
|
||||
const shell = (id: string) => ({
|
||||
id,
|
||||
tool: "shell",
|
||||
status: "exited",
|
||||
started_at: "2026-10-06T08:00:00Z",
|
||||
ended_at: "2026-10-06T08:05:00Z",
|
||||
isLive: false,
|
||||
});
|
||||
vi.mocked(api.run.history).mockImplementation(
|
||||
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
|
||||
await user.click(screen.getByText("Remove from list"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
|
||||
});
|
||||
|
||||
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 +487,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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
|
||||
* file's original text (HEAD, or a branch's merge base), editable while the
|
||||
* diff re-colours as you type. `unified` is one editor with the removed lines
|
||||
* inline above their replacement (`unifiedMergeView`); `split` is the
|
||||
* original read-only on the left beside the editable file (`MergeView`).
|
||||
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
||||
* and every changed block carries a Revert that puts the original back in the
|
||||
* editor (nothing is written until Save). Green marks a line added whole, red
|
||||
* a line removed whole, blue a line edited in place (it keeps some of its
|
||||
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { highlight, tokenClass } from "../../lib/highlight";
|
||||
|
||||
/**
|
||||
* True when a letter or digit of a line (`text`, starting at document offset
|
||||
* `from`) lies outside every changed range: an edit inside the line, not a
|
||||
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
|
||||
* a `}`) does not make two lines the same line.
|
||||
*/
|
||||
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (!/\w/.test(text[i]!)) continue;
|
||||
const at = from + i;
|
||||
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const modLine = Decoration.line({ class: "cm-ah-mod" });
|
||||
const marks = new Map<string, Decoration>();
|
||||
const mark = (cls: string) => {
|
||||
let d = marks.get(cls);
|
||||
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
|
||||
return d;
|
||||
};
|
||||
|
||||
/** Syntax marks and the edited-in-place class for the visible lines — on
|
||||
* either side of a split view (`a` is the original). */
|
||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
const info = getChunks(view.state);
|
||||
const a = info?.side === "a";
|
||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||
const chunks = info?.chunks ?? [];
|
||||
const doc = view.state.doc;
|
||||
let last = 0;
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (let pos = from; pos <= to; ) {
|
||||
const line = doc.lineAt(pos);
|
||||
pos = line.to + 1;
|
||||
if (line.number <= last) continue;
|
||||
last = line.number;
|
||||
const c = chunks.find((k) => {
|
||||
const [from, to, end] = span(k);
|
||||
return to! > from! && line.from >= from! && line.from <= end!;
|
||||
});
|
||||
const at0 = c ? span(c)[0]! : 0;
|
||||
if (
|
||||
c &&
|
||||
keepsText(
|
||||
line.text,
|
||||
line.from,
|
||||
c.changes.map((ch) =>
|
||||
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
|
||||
)
|
||||
)
|
||||
) {
|
||||
out.add(line.from, line.from, modLine);
|
||||
}
|
||||
let at = line.from;
|
||||
for (const tok of highlight(line.text, lang)) {
|
||||
const cls = tokenClass(tok.type);
|
||||
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
|
||||
at += tok.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.finish();
|
||||
}
|
||||
|
||||
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
|
||||
* classed after the fact, by `keepsText`'s rule: text outside its
|
||||
* `.cm-deletedText` spans is text the line kept. */
|
||||
function classDeleted(root: HTMLElement) {
|
||||
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
|
||||
let kept = "";
|
||||
(line.firstElementChild ?? line).childNodes.forEach((n) => {
|
||||
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
|
||||
kept += n.textContent ?? "";
|
||||
}
|
||||
});
|
||||
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
|
||||
});
|
||||
}
|
||||
|
||||
const diffColours = (lang: string) =>
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = decorate(view, lang);
|
||||
this.classLater(view);
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
|
||||
this.classLater(u.view);
|
||||
}
|
||||
// After CodeMirror has drawn, and before the browser paints.
|
||||
classLater(view: EditorView) {
|
||||
view.requestMeasure({
|
||||
key: this,
|
||||
read: () => null,
|
||||
write: () => classDeleted(view.contentDOM),
|
||||
});
|
||||
}
|
||||
},
|
||||
{ decorations: (p) => p.decorations }
|
||||
);
|
||||
|
||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||
const RED = "rgb(var(--status-danger) / 0.18)";
|
||||
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
||||
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
|
||||
// base theme, whose selectors carry an extra light/dark scope class.
|
||||
const M = "&.cm-merge-b.cm-merge-b";
|
||||
const A = "&.cm-merge-a.cm-merge-a";
|
||||
const theme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
color: "rgb(var(--fg-primary))",
|
||||
},
|
||||
"&.cm-focused": { outline: "none" },
|
||||
".cm-scroller": {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
|
||||
lineHeight: "1.45",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
color: "rgb(var(--fg-muted))",
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||
},
|
||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-changedText`]: { background: "none" },
|
||||
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
|
||||
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
|
||||
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
|
||||
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
|
||||
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
|
||||
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||
// The original, left of a split view: its changed lines are removals.
|
||||
[`${A} .cm-changedLine`]: { backgroundColor: RED },
|
||||
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${A} .cm-changedText`]: { background: "none" },
|
||||
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||
"&.cm-editor.cm-editor .cm-collapsedLines": {
|
||||
color: "rgb(var(--fg-muted))",
|
||||
background: "rgb(var(--surface-2))",
|
||||
},
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
});
|
||||
|
||||
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
||||
function revertButton(label: string, arrow = false) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = arrow ? "→" : label;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.className =
|
||||
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
|
||||
return b;
|
||||
}
|
||||
|
||||
export default function DiffEditor({
|
||||
value,
|
||||
original,
|
||||
lang,
|
||||
layout = "unified",
|
||||
collapse = false,
|
||||
label,
|
||||
revertLabel,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
original: string;
|
||||
lang: string;
|
||||
/** One editor with removals inline, or the original beside the file. */
|
||||
layout?: "unified" | "split";
|
||||
/** Fold unchanged stretches away: only the changes and their context. */
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
/** The text of each changed block's Revert button. */
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
|
||||
useEffect(() => {
|
||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||
const common = [lineNumbers(), diffColours(lang), theme];
|
||||
const editing = [
|
||||
...common,
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
cb.current.onSave();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
}),
|
||||
];
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
if (layout === "split") {
|
||||
const mv = new MergeView({
|
||||
parent: host.current!,
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
},
|
||||
b: { doc: value, extensions: editing },
|
||||
revertControls: "a-to-b",
|
||||
renderRevertControl: () => revertButton(revertLabel, true),
|
||||
collapseUnchanged: folded,
|
||||
});
|
||||
v = mv.b;
|
||||
destroy = () => mv.destroy();
|
||||
} else {
|
||||
v = new EditorView({
|
||||
parent: host.current!,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
...editing,
|
||||
unifiedMergeView({
|
||||
original,
|
||||
syntaxHighlightDeletions: false,
|
||||
collapseUnchanged: folded,
|
||||
// Accept would only hide a block; Revert is the one that edits.
|
||||
mergeControls: (type, action) => {
|
||||
const b = revertButton(revertLabel);
|
||||
if (type === "accept") b.hidden = true;
|
||||
else b.onmousedown = action;
|
||||
return b;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
destroy = () => v.destroy();
|
||||
}
|
||||
v.focus();
|
||||
view.current = v;
|
||||
return destroy;
|
||||
// One mount per view: the parent keys this component by file and shape.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
if (v && v.state.doc.toString() !== value) {
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={host}
|
||||
data-testid="lane-file-editor"
|
||||
data-layout={layout}
|
||||
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
const MIN_ROW: CSSProperties = { minHeight: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
@@ -165,7 +166,7 @@ function Windowed({
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
function langFor(filePath: string) {
|
||||
export function langFor(filePath: string) {
|
||||
const ext = filePath.split(".").pop() || "";
|
||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||
}
|
||||
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-fg-primary/15">
|
||||
<span className="rounded-sm bg-diff-mod/40">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
@@ -209,9 +210,10 @@ function emphasis(
|
||||
while (start < max && del[start] === add[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
||||
// Nothing in common at either edge: the whole line is the change, and
|
||||
// highlighting all of it adds noise on top of the row tint.
|
||||
if (start === 0 && end === 0) return null;
|
||||
// No letter or digit in common at either edge (blanks, a `;`, a `}` do not
|
||||
// count): the whole line is the change — a removed line and an added one,
|
||||
// not an edit — and highlighting all of it adds noise on top of the row tint.
|
||||
if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null;
|
||||
return {
|
||||
del: [start, del.length - end],
|
||||
add: [start, add.length - end],
|
||||
@@ -294,7 +296,8 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||||
* `style` pins a windowed row's height; an unwindowed row is at least one
|
||||
* line tall, so a split view's empty filler row keeps its column in step. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
@@ -310,7 +313,7 @@ function Row({
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
style={style ?? MIN_ROW}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
data-change-index={anchor}
|
||||
>
|
||||
@@ -320,15 +323,19 @@ function Row({
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not). */
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not).
|
||||
* Green/red mark a line added or removed whole; blue (`mod`) a line edited in
|
||||
* place — a -/+ pair that keeps part of its text, its edit emphasised. */
|
||||
function rowTint(type: LaneDiffLine["type"] | null, mod = false) {
|
||||
if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]";
|
||||
if (type === "add")
|
||||
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
|
||||
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
|
||||
return "";
|
||||
}
|
||||
|
||||
function markerClass(type: LaneDiffLine["type"]) {
|
||||
function markerClass(type: LaneDiffLine["type"], mod = false) {
|
||||
if (mod && type !== "ctx") return "font-bold text-diff-mod";
|
||||
if (type === "add") return "font-bold text-status-success";
|
||||
if (type === "del") return "font-bold text-status-danger";
|
||||
return "text-fg-muted";
|
||||
@@ -485,11 +492,11 @@ function UnifiedLine({
|
||||
}) {
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line.type, !!emph)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} commentable />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
<span className={`select-none ${markerClass(line.type, !!emph)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
@@ -558,7 +565,7 @@ function SplitLine({
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
@@ -941,16 +948,12 @@ export function LaneFileView({
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
onEditAt,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Set when the file is editable: a click on the text (0-based line, and
|
||||
* where the view was scrolled) asks to open the editor there. */
|
||||
onEditAt?: (line: number, scrollTop: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
@@ -959,12 +962,9 @@ export function LaneFileView({
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
||||
<span data-ln={i} className="contents">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
@@ -972,27 +972,7 @@ export function LaneFileView({
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
tabIndex={onEditAt ? 0 : undefined}
|
||||
onClick={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
// A click that ends a drag-selection is copying, not asking to edit.
|
||||
if (window.getSelection()?.toString()) return;
|
||||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
||||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
onKeyDown={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
onEditAt(0, e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
||||
onEditAt ? " cursor-text" : ""
|
||||
}`}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* @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 git action bar under it (`LaneGitBar`); plus the
|
||||
* uncommitted counts drawn vertical on the CLOSED Changes rail
|
||||
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
|
||||
* by the real total, listing the server's first 20, its "… more files" opening
|
||||
* the Changes panel — the open panel lists those files itself, so its header
|
||||
* shows no counts.
|
||||
*
|
||||
* 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.
|
||||
@@ -100,14 +104,20 @@ const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
onShowAll,
|
||||
anchor,
|
||||
side = "below",
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** Opens the Changes panel, which lists every file the cap left out. */
|
||||
onShowAll?: () => void;
|
||||
/** 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,25 +132,33 @@ 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.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
const total = files.length + hidden;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
aria-label={t("git.filesHeading", { count: total })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
@@ -157,7 +175,7 @@ function LaneGitFilesPanel({
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
@@ -194,16 +212,124 @@ function LaneGitFilesPanel({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
{hidden > 0 &&
|
||||
(onShowAll ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-git-files-more"
|
||||
onClick={onShowAll}
|
||||
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The uncommitted counts as a chip that opens the per-file panel on hover,
|
||||
* focus or click — the closed Changes rail, `vertical`, where nothing else
|
||||
* lists the files.
|
||||
*/
|
||||
export function LaneGitCounts({
|
||||
git,
|
||||
vertical = false,
|
||||
onShowAll,
|
||||
}: {
|
||||
git: Extract<LaneGitFacts, { available: true }>;
|
||||
vertical?: boolean;
|
||||
/** See `LaneGitFilesPanel`. */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
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"
|
||||
}`;
|
||||
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}
|
||||
onShowAll={
|
||||
onShowAll &&
|
||||
(() => {
|
||||
setFilesOpen(false);
|
||||
onShowAll();
|
||||
})
|
||||
}
|
||||
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,
|
||||
onShowAll,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
session,
|
||||
@@ -220,23 +346,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 +364,6 @@ 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
@@ -291,15 +376,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -179,7 +160,7 @@ export function WorkspaceRow({
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
|
||||
@@ -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,11 @@
|
||||
* 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) and whose hover X or
|
||||
* right-click menu forgets the row (`DELETE /api/run/history/:id`); 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,19 +32,23 @@
|
||||
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, X } 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";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, 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 +261,52 @@ 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 closedCtx = useContextMenu<RunHistoryItem>();
|
||||
const forgetShell = async (r: RunHistoryItem) => {
|
||||
try {
|
||||
await api.run.forgetHistory(r.id);
|
||||
setClosedShells((all) => all.filter((x) => x.id !== r.id));
|
||||
} 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 +349,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,10 +432,12 @@ 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>
|
||||
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
</>
|
||||
@@ -391,7 +445,11 @@ export function WorkspaceSessionList({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
<ul
|
||||
data-testid={`workspace-sessions-${lane.id}`}
|
||||
// A guide line under the workspace's chevron; the rows start past it.
|
||||
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
|
||||
>
|
||||
{fresh.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
@@ -405,8 +463,8 @@ export function WorkspaceSessionList({
|
||||
)
|
||||
}
|
||||
onContextMenu={(e) => ctx.open(e, r)}
|
||||
title={`${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] ${
|
||||
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-2 pr-2 text-left text-[12px] ${
|
||||
r.id === selectedRunId
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -414,11 +472,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"
|
||||
/>
|
||||
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
|
||||
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
|
||||
{r.startedAt ? timeAgo(r.startedAt, true) : null}
|
||||
</span>
|
||||
{r.tool === "shell" ? (
|
||||
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
) : (
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -427,7 +494,7 @@ export function WorkspaceSessionList({
|
||||
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
||||
if (renaming === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||
<li key={s.id} className="py-0.5 pr-2">
|
||||
<input
|
||||
data-testid={`workspace-session-rename-${s.id}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
@@ -481,7 +548,7 @@ export function WorkspaceSessionList({
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
: ""
|
||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -501,9 +568,12 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
@@ -521,13 +591,49 @@ export function WorkspaceSessionList({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{closed.map((r) => (
|
||||
<li key={r.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-terminal-closed-${r.id}`}
|
||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
||||
onContextMenu={(e) => closedCtx.open(e, r)}
|
||||
title={t("tree.closedTerminal")}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 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, true)}
|
||||
</span>
|
||||
<SquareTerminal
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-terminal-forget-${r.id}`}
|
||||
onClick={() => void forgetShell(r)}
|
||||
title={t("tree.forgetTerminal")}
|
||||
aria-label={t("tree.forgetTerminal")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{more > 0 && !q && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-more-${lane.id}`}
|
||||
onClick={() => own && setLimit(own.total)}
|
||||
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</button>
|
||||
@@ -544,6 +650,42 @@ export function WorkspaceSessionList({
|
||||
onClose={ctx.close}
|
||||
/>
|
||||
)}
|
||||
{closedCtx.menu && (
|
||||
<ContextMenu
|
||||
x={closedCtx.menu.x}
|
||||
y={closedCtx.menu.y}
|
||||
onClose={closedCtx.close}
|
||||
testId={`terminal-menu-${closedCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
closedCtx.close();
|
||||
void reopenShell(false);
|
||||
}}
|
||||
>
|
||||
{t("tree.reopenTerminal")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
closedCtx.close();
|
||||
void reopenShell(true);
|
||||
}}
|
||||
>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const item = closedCtx.menu!.item;
|
||||
closedCtx.close();
|
||||
void forgetShell(item);
|
||||
}}
|
||||
>
|
||||
{t("tree.forgetTerminal")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{sessionCtx.menu && (
|
||||
<SessionMenu
|
||||
x={sessionCtx.menu.x}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||
* count as edited in place (blue) rather than added or removed whole, and the
|
||||
* editor's own diff classes on a real mount.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
|
||||
Range.prototype.getClientRects ??= () => EMPTY;
|
||||
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
|
||||
|
||||
describe("keepsText", () => {
|
||||
it("is false for a line whose every non-blank character changed", () => {
|
||||
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
|
||||
expect(keepsText("", 0, [])).toBe(false);
|
||||
// Only a `;` survived: not the same line.
|
||||
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when some text outside the changes survives", () => {
|
||||
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiffEditor", () => {
|
||||
it("marks a whole new line green and a line edited in place blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"keep\nconst x = 2;\nbrand new"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
|
||||
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
|
||||
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
|
||||
);
|
||||
});
|
||||
|
||||
it("side by side: the original read-only on the left, its edited line blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
value={"keep\nconst x = 2;"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
|
||||
expect(left).toHaveAttribute("contenteditable", "false");
|
||||
expect(right).toHaveAttribute("contenteditable", "true");
|
||||
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
// The revert column is drawn in CodeMirror's next measure pass, not at mount.
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert")
|
||||
);
|
||||
});
|
||||
|
||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
collapse
|
||||
value={`${same}\nchanged`}
|
||||
original={`${same}\noriginal`}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,42 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
original: string;
|
||||
layout?: string;
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
onChange: (v: string) => void;
|
||||
onSave: () => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="lane-file-editor"
|
||||
data-original={p.original}
|
||||
data-layout={p.layout}
|
||||
data-collapse={String(!!p.collapse)}
|
||||
aria-label={p.label}
|
||||
autoFocus
|
||||
value={p.value}
|
||||
onChange={(e) => p.onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
p.onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
@@ -32,8 +61,11 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitBranches: vi.fn(),
|
||||
changes: vi.fn(),
|
||||
gitAction: vi.fn(),
|
||||
suggestCommitMessage: 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(),
|
||||
@@ -88,14 +120,20 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
|
||||
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
name: "Discard this file's changes",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -111,6 +149,7 @@ beforeEach(() => {
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
parents: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -138,8 +177,8 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
|
||||
* panel — the tab is the Workspace's state, passed in as `tab`. */
|
||||
/** The panel as the Workspace renders it: Files and Changes are its own mode
|
||||
* buttons now, no activity-bar tab around it. */
|
||||
function Tabbed({
|
||||
laneId,
|
||||
onDirtyChange,
|
||||
@@ -147,18 +186,7 @@ function Tabbed({
|
||||
laneId: number;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"files" | "changes">("changes");
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setTab("files")}>
|
||||
Files
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("changes")}>
|
||||
Changes
|
||||
</button>
|
||||
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
|
||||
</>
|
||||
);
|
||||
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
|
||||
}
|
||||
|
||||
describe("LaneChangesPanel", () => {
|
||||
@@ -167,7 +195,7 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
|
||||
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(screen.getByText("+12")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/new line/)).toBeNull();
|
||||
});
|
||||
@@ -192,7 +220,7 @@ describe("LaneChangesPanel", () => {
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
@@ -201,6 +229,7 @@ describe("LaneChangesPanel", () => {
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
await toDiff(user);
|
||||
// The highlighter splits a line into one span per token, so assert on the
|
||||
// rendered text of the whole view rather than on a single node.
|
||||
const view = await screen.findByTestId("lane-diff-view");
|
||||
@@ -256,6 +285,65 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
||||
await user.click(keep!); // the first row: server/lib/git-ops.js
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
||||
"server/lib/git-ops.js",
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||
// And back: the row's button now offers the way out.
|
||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||
});
|
||||
|
||||
it("draws a never-stage file orange in the Files tree", async () => {
|
||||
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
});
|
||||
|
||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
// `server/lib` holds nothing but `lib`: one compacted row.
|
||||
const folder = within(list).getByTitle("server/lib");
|
||||
expect(folder).toHaveTextContent("1 file");
|
||||
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
|
||||
await user.click(folder);
|
||||
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
await user.click(
|
||||
within(folder.parentElement!).getByRole("button", {
|
||||
name: "Stage every file in this folder",
|
||||
})
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({
|
||||
available: true,
|
||||
@@ -339,16 +427,26 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fills the commit box with the AI-suggested message", async () => {
|
||||
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTestId("lane-changes-suggest-message"));
|
||||
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
|
||||
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
||||
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -372,15 +470,41 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
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 straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -405,29 +529,29 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// Opening a file only shows it: no editor until the text itself is clicked.
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
await user.click(view);
|
||||
// Opening a file is editing it, diffed against its HEAD text.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(editor).toHaveFocus();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
|
||||
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel asks before dropping an edit: "keep" keeps it…
|
||||
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
||||
// …and "discard" drops it and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// …and "discard" reverts it, still editing.
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
||||
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
@@ -440,32 +564,41 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
// Changes is the editor folded to its changes, side by side at full width.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("a");
|
||||
expect(editor).toHaveAttribute("data-collapse", "true");
|
||||
expect(editor).toHaveAttribute("data-layout", "split");
|
||||
await user.type(editor, "b");
|
||||
// Whole file unfolds the same edit — no prompt, nothing dropped.
|
||||
vi.mocked(confirmDiscard).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
||||
const whole = await screen.findByTestId("lane-file-editor");
|
||||
expect(whole).toHaveAttribute("data-collapse", "false");
|
||||
expect(whole).toHaveValue("ab");
|
||||
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
|
||||
expect(confirmDiscard).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
||||
it("opens diffs unified at the narrow width", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["copy me"],
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "copy",
|
||||
} as unknown as Selection);
|
||||
await user.click(view);
|
||||
spy.mockRestore();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
@@ -489,7 +622,7 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByText("server/lib/git-ops.js");
|
||||
const row = within(list).getByTitle("server/lib/git-ops.js");
|
||||
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
||||
|
||||
await right();
|
||||
@@ -549,9 +682,9 @@ describe("LaneChangesPanel", () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
// A plain refresh (the poll's twin), not a git action — an action already disarms.
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
|
||||
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await screen.findByText("server/lib/git-ops.js");
|
||||
await screen.findByTitle("server/lib/git-ops.js");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
||||
).toBeNull();
|
||||
@@ -584,7 +717,7 @@ describe("LaneChangesPanel", () => {
|
||||
resolveOld(changes());
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
||||
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a commit from history as a commit-range review", async () => {
|
||||
@@ -593,6 +726,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -738,7 +872,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
await user.clear(editor);
|
||||
@@ -751,36 +884,9 @@ describe("LaneChangesPanel", () => {
|
||||
"const whole = 2;",
|
||||
"b".repeat(40)
|
||||
);
|
||||
// Back to reading, showing what was saved.
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
// The Workspace asked before switching tab; the edit must then be GONE, or
|
||||
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
|
||||
it("drops an edited file when the tab switches away from it", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const dirty = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "x");
|
||||
expect(dirty).toHaveBeenLastCalledWith(true);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// Still editing, on what was saved: nothing left to save.
|
||||
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
|
||||
});
|
||||
|
||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||
@@ -824,7 +930,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
@@ -864,7 +969,8 @@ describe("LaneChangesPanel", () => {
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||
@@ -875,7 +981,8 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||
|
||||
// Collapse is dead at the tightest step, and live once expanded — an
|
||||
@@ -905,8 +1012,8 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
@@ -967,11 +1074,10 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
|
||||
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
||||
for (const name of ["Overview", "Review", "History"]) {
|
||||
for (const name of ["Changes", "Files", "Review", "History"]) {
|
||||
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
@@ -1323,9 +1429,9 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
const before = screen.getByText("server/lib/git-ops.js");
|
||||
const before = screen.getByTitle("server/lib/git-ops.js");
|
||||
await user.type(screen.getByLabelText("Commit message"), "x");
|
||||
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
|
||||
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
|
||||
});
|
||||
|
||||
it("expands all folders with ONE request, not one per folder", async () => {
|
||||
@@ -1390,7 +1496,7 @@ describe("LaneChangesPanel", () => {
|
||||
lane7Reads
|
||||
);
|
||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
||||
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
|
||||
|
||||
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||
// Two lines removed, none added: the right column's two rows are fillers.
|
||||
const file = fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||
]);
|
||||
render(<LaneDiffView file={file} layout="split" />);
|
||||
const fillers = [
|
||||
...screen
|
||||
.getByTestId("lane-diff-split-right")
|
||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
expect(fillers).toHaveLength(2);
|
||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
// A line added or removed whole: nothing in common with its pair.
|
||||
const WHOLE = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
|
||||
]);
|
||||
|
||||
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const add = rowOf(container, "xyz");
|
||||
const del = rowOf(container, "abc");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
|
||||
});
|
||||
|
||||
it("tints a line edited in place blue on both sides", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
for (const text of ["x = 1", "x = 2"]) {
|
||||
const row = rowOf(container, text);
|
||||
expect(row.className).toContain("bg-diff-mod/20");
|
||||
expect(row.className).not.toMatch(/status-(success|danger)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const plus = marker(rowOf(container, "xyz"));
|
||||
const minus = marker(rowOf(container, "abc"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
|
||||
@@ -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", () => ({
|
||||
@@ -48,7 +49,7 @@ describe("LaneGitHeader", () => {
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
it("renders the live branch, head and subject", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
@@ -56,7 +57,6 @@ describe("LaneGitHeader", () => {
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
@@ -70,9 +70,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 +98,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 +115,37 @@ 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("40 files changed");
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("opens the Changes panel from the file list's “more files”", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
const onShowAll = vi.fn();
|
||||
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
fireEvent.click(screen.getByTestId("lane-git-files-more"));
|
||||
expect(onShowAll).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no counts in the open panel's header: the list below already does", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
await screen.findByTestId("lane-git");
|
||||
expect(screen.queryByTestId("lane-git-counts")).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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and
|
||||
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||||
@@ -11,7 +13,8 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -61,7 +64,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
|
||||
const reset = async () => {
|
||||
@@ -78,67 +81,99 @@ export function AppearanceSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-5 space-y-5">
|
||||
<SettingsRow
|
||||
label={t("appearance.mode")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("appearance.mode")}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={MODES.map(({ mode: m, Icon }) => ({
|
||||
value: m,
|
||||
label: (
|
||||
<>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(`appearance.modes.${m}`)}
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
description={t("language.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("language.label")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: t("language.names.en") },
|
||||
{ value: "vi" as const, label: t("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{(["dark", "light"] as const).map((group) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t(`appearance.${group}Themes`)}
|
||||
{theme === group && (
|
||||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
label={t("appearance.mode")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("appearance.mode")}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={MODES.map(({ mode: m, Icon }) => ({
|
||||
value: m,
|
||||
label: (
|
||||
<>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(`appearance.modes.${m}`)}
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{(["dark", "light"] as const).map((group) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t(`appearance.${group}Themes`)}
|
||||
{theme === group && (
|
||||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
|
||||
<div className="flex justify-end border-t border-border pt-3">
|
||||
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("appearance.reset")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-border pt-3">
|
||||
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("appearance.reset")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pet" className="space-y-3 scroll-mt-24">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.pet")}</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("appearance.petDescription")}</p>
|
||||
</div>
|
||||
<PetSettings />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file PetSettings — Settings → Pet: the mascot's own page. Show it or not,
|
||||
* @file PetSettings — the pet block of Settings → Appearance (`#pet`). Show it or not,
|
||||
* its speech bubbles, its size (with a live preview), and which pet it wears
|
||||
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
|
||||
* install on the first pick, never bundled), and your own (deletable) — plus
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18n from "../../../i18n";
|
||||
import { AppearanceSettings } from "../AppearanceSettings";
|
||||
|
||||
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
describe("AppearanceSettings", () => {
|
||||
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
|
||||
root.removeAttribute("data-mode");
|
||||
});
|
||||
|
||||
it("switches the interface language and holds the pet block", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
expect(screen.getByTestId("pet-settings").closest("section")).toHaveAttribute("id", "pet");
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||
expect(i18n.language).toBe("vi");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders every theme of both modes, defaults selected", () => {
|
||||
render(<AppearanceSettings />);
|
||||
const pressed = screen
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
"justNow": "just now",
|
||||
"mAgo": "{{count}}m ago",
|
||||
"hAgo": "{{count}}h ago",
|
||||
"dAgo": "{{count}}d ago"
|
||||
"dAgo": "{{count}}d ago",
|
||||
"justNowShort": "now",
|
||||
"mAgoShort": "{{count}}m",
|
||||
"hAgoShort": "{{count}}h",
|
||||
"dAgoShort": "{{count}}d"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous",
|
||||
|
||||
@@ -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.",
|
||||
@@ -57,6 +59,10 @@
|
||||
"changes.collapseFile": "Collapse {{path}}",
|
||||
"changes.commitPlaceholder": "Commit message",
|
||||
"changes.commitStaged": "Commit {{count}} staged",
|
||||
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
|
||||
"changes.neverStage": "Never stage — Stage all skips this file",
|
||||
"changes.allowStage": "Include in Stage all again",
|
||||
"changes.keptHint": "Never staged by Stage all (this browser)",
|
||||
"changes.conflicted": "conflict",
|
||||
"changes.discard": "Discard this file's changes",
|
||||
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
||||
@@ -80,7 +86,7 @@
|
||||
"changes.loading": "Reading the working copy…",
|
||||
"changes.mode.browse": "Files",
|
||||
"changes.mode.history": "History",
|
||||
"changes.mode.overview": "Overview",
|
||||
"changes.mode.overview": "Changes",
|
||||
"changes.mode.review": "Review",
|
||||
"changes.noCommits": "No commits yet.",
|
||||
"changes.noHunks": "No textual change.",
|
||||
@@ -96,6 +102,10 @@
|
||||
"changes.size.compact": "Narrow",
|
||||
"changes.size.half": "Medium",
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.fileCount_one": "{{count}} file",
|
||||
"changes.fileCount_other": "{{count}} files",
|
||||
"changes.stageFolder": "Stage every file in this folder",
|
||||
"changes.unstageFolder": "Unstage every file in this folder",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
@@ -106,6 +116,7 @@
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
"changes.unavailable": "This workspace's directory is not a readable git repository.",
|
||||
"changes.unstage": "Unstage this file",
|
||||
"changes.changesView": "Changes",
|
||||
"changes.wholeFile": "Whole file",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
@@ -232,8 +243,8 @@
|
||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.revertBlock": "Revert",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.clickToEdit": "Click the text to edit",
|
||||
"changes.preview": "Preview",
|
||||
"changes.raw": "Raw",
|
||||
"changes.editorLabel": "Edit {{path}}",
|
||||
@@ -241,6 +252,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,14 +303,11 @@
|
||||
"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",
|
||||
"menu.reset": "Reset worktree…",
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.files": "Files",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
@@ -321,6 +330,9 @@
|
||||
"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.reopenTerminal": "Open a new terminal",
|
||||
"tree.forgetTerminal": "Remove from list",
|
||||
"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 +361,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 +392,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 +400,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 +413,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 +426,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 +447,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",
|
||||
|
||||
@@ -66,7 +66,21 @@
|
||||
"needYou": "{{count}} need you",
|
||||
"today": "Today {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}"
|
||||
"session": "Session {{cost}}",
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "{{pct}}% used",
|
||||
"quotaTitle": "Usage",
|
||||
"quotaResets": "Resets in {{time}}",
|
||||
"quotaAsOf": "as of {{ago}}",
|
||||
"quotaRefresh": "Refresh",
|
||||
"quotaRefreshing": "Refreshing…",
|
||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaActive": "in use",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
"quotaWindow": { "wk": "Week", "mo": "Month" }
|
||||
},
|
||||
"version": "Version",
|
||||
"openMenu": "Open menu",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"import": "Import .codex-pet bundle…",
|
||||
"browse": "Find more on codex-pets.net",
|
||||
"settings": "Pet settings…",
|
||||
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.",
|
||||
"hiddenToast": "Pet hidden. Show it again in Settings → Appearance.",
|
||||
"deleteTitle": "Delete {{name}}?",
|
||||
"deleteMessage": "The pet's files are removed from this install. A featured pet can be downloaded again; an upload is gone for good.",
|
||||
"deleteConfirm": "Delete pet",
|
||||
@@ -587,6 +587,8 @@
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"fieldBehavesAs": "Behaves as (Claude Code)",
|
||||
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
@@ -651,7 +653,10 @@
|
||||
"reset": "Reset to defaults",
|
||||
"resetTitle": "Reset appearance?",
|
||||
"resetBody": "Mode goes back to Dark and both modes to their default theme.",
|
||||
"resetDone": "Appearance reset."
|
||||
"resetDone": "Appearance reset.",
|
||||
"theme": "Theme",
|
||||
"pet": "Pet",
|
||||
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -751,17 +756,9 @@
|
||||
"cost": "Cost"
|
||||
},
|
||||
"section": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "The dashboard's display language."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
"description": "Language, colour mode and theme, and the corner pet."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
"justNow": "vừa xong",
|
||||
"mAgo": "{{count}} phút trước",
|
||||
"hAgo": "{{count}} giờ trước",
|
||||
"dAgo": "{{count}} ngày trước"
|
||||
"dAgo": "{{count}} ngày trước",
|
||||
"justNowShort": "vừa xong",
|
||||
"mAgoShort": "{{count}} phút",
|
||||
"hAgoShort": "{{count}} giờ",
|
||||
"dAgoShort": "{{count}} ngày"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Trước",
|
||||
|
||||
@@ -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ị.",
|
||||
@@ -57,6 +59,10 @@
|
||||
"changes.collapseFile": "Thu gọn {{path}}",
|
||||
"changes.commitPlaceholder": "Nội dung commit",
|
||||
"changes.commitStaged": "Commit {{count}} file đã stage",
|
||||
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
|
||||
"changes.neverStage": "Không bao giờ stage — Stage all bỏ qua file này",
|
||||
"changes.allowStage": "Cho Stage all stage lại file này",
|
||||
"changes.keptHint": "Stage all không bao giờ stage file này (trên trình duyệt này)",
|
||||
"changes.conflicted": "xung đột",
|
||||
"changes.discard": "Bỏ thay đổi của file này",
|
||||
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
|
||||
@@ -80,7 +86,7 @@
|
||||
"changes.loading": "Đang đọc working copy…",
|
||||
"changes.mode.browse": "Tệp",
|
||||
"changes.mode.history": "Lịch sử",
|
||||
"changes.mode.overview": "Tổng quan",
|
||||
"changes.mode.overview": "Thay đổi",
|
||||
"changes.mode.review": "Xem toàn bộ",
|
||||
"changes.noCommits": "Chưa có commit nào.",
|
||||
"changes.noHunks": "Không có thay đổi nội dung.",
|
||||
@@ -96,6 +102,10 @@
|
||||
"changes.size.compact": "Hẹp",
|
||||
"changes.size.half": "Vừa",
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.fileCount_one": "{{count}} tệp",
|
||||
"changes.fileCount_other": "{{count}} tệp",
|
||||
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
|
||||
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
@@ -106,6 +116,7 @@
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
|
||||
"changes.unstage": "Bỏ file này khỏi index",
|
||||
"changes.changesView": "Thay đổi",
|
||||
"changes.wholeFile": "Toàn bộ file",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
@@ -232,8 +243,8 @@
|
||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.revertBlock": "Hoàn tác",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.clickToEdit": "Bấm vào nội dung để sửa",
|
||||
"changes.preview": "Xem trước",
|
||||
"changes.raw": "Mã nguồn",
|
||||
"changes.editorLabel": "Sửa {{path}}",
|
||||
@@ -241,6 +252,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,14 +303,11 @@
|
||||
"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",
|
||||
"menu.reset": "Đặt lại worktree…",
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.files": "Tệp",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
@@ -321,6 +330,9 @@
|
||||
"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.reopenTerminal": "Mở terminal mới",
|
||||
"tree.forgetTerminal": "Xóa khỏi danh sách",
|
||||
"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 +361,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 +392,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 +400,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 +413,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 +426,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 +447,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",
|
||||
|
||||
@@ -66,7 +66,21 @@
|
||||
"needYou": "{{count}} cần bạn",
|
||||
"today": "Hôm nay {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}"
|
||||
"session": "Session {{cost}}",
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "đã dùng {{pct}}%",
|
||||
"quotaTitle": "Mức dùng",
|
||||
"quotaResets": "Đặt lại sau {{time}}",
|
||||
"quotaAsOf": "lúc {{ago}}",
|
||||
"quotaRefresh": "Làm mới",
|
||||
"quotaRefreshing": "Đang làm mới…",
|
||||
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
|
||||
"quotaFailed": "Không đọc được quota",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||
"quotaWindow": { "wk": "Tuần", "mo": "Tháng" }
|
||||
},
|
||||
"version": "Phiên bản",
|
||||
"openMenu": "Mở menu",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"import": "Nhập bundle .codex-pet…",
|
||||
"browse": "Tìm thêm trên codex-pets.net",
|
||||
"settings": "Cài đặt pet…",
|
||||
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Pet.",
|
||||
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Giao diện.",
|
||||
"deleteTitle": "Xoá {{name}}?",
|
||||
"deleteMessage": "Các tệp của pet sẽ bị xoá khỏi bản cài này. Pet nổi bật có thể tải lại; ảnh bạn tải lên sẽ mất hẳn.",
|
||||
"deleteConfirm": "Xoá pet",
|
||||
@@ -587,6 +587,8 @@
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"fieldBehavesAs": "Hoạt động như (Claude Code)",
|
||||
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
@@ -651,7 +653,10 @@
|
||||
"reset": "Khôi phục mặc định",
|
||||
"resetTitle": "Khôi phục giao diện?",
|
||||
"resetBody": "Chế độ trở về Tối và cả hai chế độ trở về giao diện mặc định.",
|
||||
"resetDone": "Đã khôi phục giao diện."
|
||||
"resetDone": "Đã khôi phục giao diện.",
|
||||
"theme": "Theme",
|
||||
"pet": "Pet",
|
||||
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -751,17 +756,9 @@
|
||||
"cost": "Chi phí"
|
||||
},
|
||||
"section": {
|
||||
"general": {
|
||||
"title": "Chung",
|
||||
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Thông báo",
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
--syn-plain: var(--fg-primary);
|
||||
--syn-add: 4 120 87; /* emerald-700 */
|
||||
--syn-del: 185 28 28; /* red-700 #b91c1c */
|
||||
/* A diff line edited in place (not wholly added or removed). */
|
||||
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
|
||||
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
|
||||
--kept: 194 65 12; /* orange-700 #c2410c */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -154,6 +158,8 @@
|
||||
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
|
||||
--syn-add: 110 231 183; /* emerald-300 */
|
||||
--syn-del: 252 165 165; /* red-300 #fca5a5 */
|
||||
--diff-mod: 96 165 250; /* blue-400 #60a5fa */
|
||||
--kept: 251 146 60; /* orange-400 #fb923c */
|
||||
}
|
||||
|
||||
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's folder tree: grouping by folder,
|
||||
* compacting single-child chains, counts, ordering and the per-folder file list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { changeTree, treeFiles } from "../changeTree";
|
||||
|
||||
const rows = [
|
||||
"app2/Services/TwitterService.php",
|
||||
"app2/Services/Redis/SlowAlertRedisService.php",
|
||||
"app2/Enums/Redis/RedisSlowAlertKey.php",
|
||||
"config/monitoring.php",
|
||||
"config/app.php",
|
||||
"README.md",
|
||||
].map((path) => ({ path }));
|
||||
|
||||
describe("changeTree", () => {
|
||||
const root = changeTree(rows);
|
||||
|
||||
it("puts root files at the top level and counts every file", () => {
|
||||
expect(root.count).toBe(6);
|
||||
expect(root.files.map((f) => f.name)).toEqual(["README.md"]);
|
||||
expect(root.dirs.map((d) => d.name)).toEqual(["app2", "config"]);
|
||||
});
|
||||
|
||||
it("compacts a folder chain with nothing else in it", () => {
|
||||
const app2 = root.dirs[0]!;
|
||||
expect(app2.count).toBe(3);
|
||||
expect(app2.dirs.map((d) => [d.name, d.path])).toEqual([
|
||||
["Enums/Redis", "app2/Enums/Redis"],
|
||||
["Services", "app2/Services"],
|
||||
]);
|
||||
// Services holds a file, so it is not folded into Redis.
|
||||
expect(app2.dirs[1]!.dirs[0]!.name).toBe("Redis");
|
||||
});
|
||||
|
||||
it("sorts files by name and lists a folder's files", () => {
|
||||
expect(root.dirs[1]!.files.map((f) => f.name)).toEqual(["app.php", "monitoring.php"]);
|
||||
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeTree — an untracked folder", () => {
|
||||
it("shows git's `dir/` entry as a named row in its parent folder", () => {
|
||||
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
|
||||
const docs = root.dirs[0]!;
|
||||
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
|
||||
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file Tests for the History commit-graph layout: a straight line, a branch
|
||||
* merged back, converging lanes and a parent beyond the loaded page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { commitGraph } from "../commitGraph";
|
||||
|
||||
const c = (sha: string, ...parents: string[]) => ({ sha, parents });
|
||||
|
||||
describe("commitGraph", () => {
|
||||
it("draws a linear history in one column", () => {
|
||||
const { rows, width } = commitGraph([c("c", "b"), c("b", "a"), c("a")]);
|
||||
expect(width).toBe(1);
|
||||
expect(rows.map((r) => r.col)).toEqual([0, 0, 0]);
|
||||
// The tip has no line from above; the root none below.
|
||||
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
|
||||
expect(rows[2]?.segs).toEqual([{ x1: 0, y1: 0, x2: 0, y2: 0.5, color: 0 }]);
|
||||
});
|
||||
|
||||
it("opens a lane for a merged branch and closes it at the fork point", () => {
|
||||
// m merge of b2 into a2
|
||||
// |\\
|
||||
// a2 b2
|
||||
// | b1
|
||||
// |/
|
||||
// a1
|
||||
const { rows, width } = commitGraph([
|
||||
c("m", "a2", "b2"),
|
||||
c("a2", "a1"),
|
||||
c("b2", "b1"),
|
||||
c("b1", "a1"),
|
||||
c("a1"),
|
||||
]);
|
||||
expect(width).toBe(2);
|
||||
expect(rows.map((r) => r.col)).toEqual([0, 0, 1, 1, 0]);
|
||||
expect(rows[0]?.segs).toContainEqual({ x1: 0, y1: 0.5, x2: 1, y2: 1, color: 1 });
|
||||
// b1's lane converges into a1's column.
|
||||
expect(rows[4]?.segs).toContainEqual({ x1: 1, y1: 0, x2: 0, y2: 0.5, color: 1 });
|
||||
expect(rows[1]?.color).toBe(rows[0]?.color);
|
||||
expect(rows[2]?.color).not.toBe(rows[0]?.color);
|
||||
});
|
||||
|
||||
it("keeps a lane running for a parent past the page", () => {
|
||||
const { rows } = commitGraph([c("b", "a")]);
|
||||
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+58
-1
@@ -390,6 +390,7 @@ import type {
|
||||
CodexHooksStatus,
|
||||
AntigravityHooksStatus,
|
||||
CostResult,
|
||||
ToolQuota,
|
||||
DashboardEvent,
|
||||
Lane,
|
||||
LaneChangeRange,
|
||||
@@ -1380,16 +1381,23 @@ 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()}`);
|
||||
},
|
||||
/** GET /api/pricing/quota - plan quota spent per native tool; `force` for a Refresh. */
|
||||
quota: (force = false) =>
|
||||
request<{ tools: ToolQuota[]; checkedAt: string }>(
|
||||
`/pricing/quota${force ? "?force=1" : ""}`
|
||||
),
|
||||
/**
|
||||
* GET /api/pricing/cost/:sessionId - cost for one session, priced as of
|
||||
* the session's start date.
|
||||
@@ -2176,6 +2184,17 @@ 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()}`);
|
||||
},
|
||||
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
|
||||
forgetHistory: (id: string) =>
|
||||
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** 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 +2626,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 +2699,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
|
||||
@@ -2730,6 +2766,12 @@ export const api = {
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(gitUrl(id, "/branches")),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
|
||||
* written by the user's `claude` CLI (no tools, nothing persisted).
|
||||
*/
|
||||
suggestCommitMessage: (id: GitTarget) =>
|
||||
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
@@ -3259,6 +3301,8 @@ export interface AiRouter {
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
||||
behavesAs?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
@@ -3291,6 +3335,8 @@ export interface AiRouterInput {
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
@@ -4011,6 +4057,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"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Folds the Changes panel's flat list of changed files into a folder
|
||||
* tree, JetBrains' commit-view shape: folders first, then files, each sorted
|
||||
* by name, and a chain of folders that hold nothing but one sub-folder drawn
|
||||
* as ONE row (`src/lib`), so a deep path does not cost a row per level.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export interface ChangeTreeDir<T> {
|
||||
/** The row's label — one segment, or several joined by `/` when compacted. */
|
||||
name: string;
|
||||
/** The folder's repo-relative path ("" for the root). */
|
||||
path: string;
|
||||
dirs: ChangeTreeDir<T>[];
|
||||
files: { name: string; file: T }[];
|
||||
/** Every file anywhere below. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
||||
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
||||
for (const file of rows) {
|
||||
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
|
||||
const parts = file.path.replace(/\/$/, "").split("/");
|
||||
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
|
||||
let dir = root;
|
||||
dir.count++;
|
||||
for (const seg of parts.slice(0, -1)) {
|
||||
const path = dir.path ? `${dir.path}/${seg}` : seg;
|
||||
let next = dir.dirs.find((d) => d.path === path);
|
||||
if (!next) dir.dirs.push((next = { name: seg, path, dirs: [], files: [], count: 0 }));
|
||||
next.count++;
|
||||
dir = next;
|
||||
}
|
||||
dir.files.push({ name: parts[parts.length - 1] ?? file.path, file });
|
||||
}
|
||||
const finish = (d: ChangeTreeDir<T>): ChangeTreeDir<T> => {
|
||||
// Never the root: its children are the group's top level.
|
||||
let only: ChangeTreeDir<T> | undefined;
|
||||
while (d.path && d.files.length === 0 && d.dirs.length === 1 && (only = d.dirs[0])) {
|
||||
d = { ...only, name: `${d.name}/${only.name}` };
|
||||
}
|
||||
d.dirs = d.dirs.map(finish).sort((a, b) => a.name.localeCompare(b.name));
|
||||
d.files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return d;
|
||||
};
|
||||
return finish(root);
|
||||
}
|
||||
|
||||
/** Every file under a folder, for its stage/unstage-all button. */
|
||||
export function treeFiles<T>(d: ChangeTreeDir<T>): T[] {
|
||||
return [...d.files.map((f) => f.file), ...d.dirs.flatMap(treeFiles)];
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @file Lays out the Changes panel's History as a commit graph (the JetBrains /
|
||||
* `git log --graph` shape): each commit gets a column, and each row lists the
|
||||
* line segments drawn through it — a lane passing by, the lanes converging on
|
||||
* the commit from above, and the lines leaving it for its parents below.
|
||||
* Expects the commits in an order where no parent precedes a child
|
||||
* (`git log --date-order`); a parent outside the list leaves its lane running
|
||||
* off the bottom.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** One line in a row, in column units; y runs 0 (top) → 0.5 (node) → 1 (bottom). */
|
||||
export interface GraphSeg {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
color: number;
|
||||
}
|
||||
|
||||
export interface GraphRow {
|
||||
col: number;
|
||||
color: number;
|
||||
segs: GraphSeg[];
|
||||
}
|
||||
|
||||
export function commitGraph(commits: { sha: string; parents: string[] }[]): {
|
||||
rows: GraphRow[];
|
||||
width: number;
|
||||
} {
|
||||
// lanes[j] = the sha the lane in column j is waiting for; colors[j] its hue.
|
||||
const lanes: (string | null)[] = [];
|
||||
const colors: number[] = [];
|
||||
const hue = (j: number) => colors[j] ?? 0;
|
||||
let nextColor = 0;
|
||||
let width = 1;
|
||||
const open = () => {
|
||||
const free = lanes.indexOf(null);
|
||||
const j = free < 0 ? lanes.length : free;
|
||||
colors[j] = nextColor++;
|
||||
return j;
|
||||
};
|
||||
|
||||
const rows = commits.map((c) => {
|
||||
const segs: GraphSeg[] = [];
|
||||
let col = lanes.indexOf(c.sha);
|
||||
// A tip nothing above points at: a lane of its own, no line from the top.
|
||||
if (col < 0) {
|
||||
col = open();
|
||||
lanes[col] = null;
|
||||
}
|
||||
lanes.forEach((s, j) => {
|
||||
if (s === null) return;
|
||||
segs.push(
|
||||
s === c.sha
|
||||
? { x1: j, y1: 0, x2: col, y2: 0.5, color: hue(j) }
|
||||
: { x1: j, y1: 0, x2: j, y2: 1, color: hue(j) }
|
||||
);
|
||||
});
|
||||
const color = hue(col);
|
||||
lanes.forEach((s, j) => {
|
||||
if (s === c.sha) lanes[j] = null;
|
||||
});
|
||||
|
||||
c.parents.forEach((p, i) => {
|
||||
let j = i === 0 ? col : lanes.indexOf(p);
|
||||
if (j < 0) j = open();
|
||||
lanes[j] = p;
|
||||
segs.push({ x1: col, y1: 0.5, x2: j, y2: 1, color: hue(j) });
|
||||
});
|
||||
while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();
|
||||
width = Math.max(width, lanes.length, col + 1);
|
||||
return { col, color, segs };
|
||||
});
|
||||
return { rows, width };
|
||||
}
|
||||
@@ -337,23 +337,26 @@ export function formatMs(ms: number): string {
|
||||
* Formats how long ago an ISO/SQLite timestamp was, as a translated relative
|
||||
* string ("just now", "5m ago", "3h ago", "2d ago") using {@link i18n}.
|
||||
* @param iso A past timestamp (see {@link parseDate}).
|
||||
* @param short Drop the "ago" ("now", "5m") — the `common:time.*Short` keys.
|
||||
* @returns A localized relative-time phrase; i18next handles pluralization via `count`.
|
||||
* @remarks Thresholds cascade seconds -> minutes -> hours -> days (days is the largest
|
||||
* bucket, so a 40-day-old event reads "40d ago"). Under a minute collapses to the
|
||||
* "just now" key. Uses translated keys, not `Intl.RelativeTimeFormat`, so the exact
|
||||
* wording is controlled by the `common:time.*` translation resources.
|
||||
*/
|
||||
export function timeAgo(iso: string): string {
|
||||
export function timeAgo(iso: string, short = false): string {
|
||||
const ms = Date.now() - parseDate(iso).getTime();
|
||||
if (Number.isNaN(ms)) return "-";
|
||||
// `short` drops the "ago" ("17m"), for a narrow column like the sidebar's.
|
||||
const k = short ? "Short" : "";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return i18n.t("common:time.justNow");
|
||||
if (seconds < 60) return i18n.t(`common:time.justNow${k}`);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return i18n.t("common:time.mAgo", { count: minutes });
|
||||
if (minutes < 60) return i18n.t(`common:time.mAgo${k}`, { count: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return i18n.t("common:time.hAgo", { count: hours });
|
||||
if (hours < 24) return i18n.t(`common:time.hAgo${k}`, { count: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
return i18n.t("common:time.dAgo", { count: days });
|
||||
return i18n.t(`common:time.dAgo${k}`, { count: days });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* @description Keyboard helpers shared by every single-line submit field.
|
||||
* `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese
|
||||
* Telex, CJK): without it a rename or commit message is committed half-typed.
|
||||
* `copyOnCtrlShiftC` makes Ctrl+Shift+C a copy, as in a Linux terminal, instead
|
||||
* of the browser's inspect-element shortcut (App.tsx installs it, capture phase).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -13,3 +15,14 @@ export function isSubmitEnter(e: ReactKeyboardEvent | KeyboardEvent): boolean {
|
||||
const native = "nativeEvent" in e ? e.nativeEvent : e;
|
||||
return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+Shift+C copies the selection — a field's, the page's or a terminal's
|
||||
* (xterm answers the `copy` event) — instead of opening DevTools. F12 and
|
||||
* Ctrl+Shift+I still open them. With nothing selected it does nothing.
|
||||
*/
|
||||
export function copyOnCtrlShiftC(e: KeyboardEvent): void {
|
||||
if (e.code !== "KeyC" || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
document.execCommand("copy");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ export const SETTINGS_INDEX: {
|
||||
to?: string;
|
||||
}[] = [
|
||||
{
|
||||
section: "general",
|
||||
section: "appearance",
|
||||
titleKey: "language.title",
|
||||
to: "#language",
|
||||
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||
},
|
||||
{
|
||||
@@ -90,14 +91,20 @@ export const SETTINGS_INDEX: {
|
||||
keywords: ["version", "server", "database", "uptime"],
|
||||
},
|
||||
{
|
||||
section: "pet",
|
||||
section: "appearance",
|
||||
titleKey: "sagi.title",
|
||||
descKey: "sagi.description",
|
||||
keywords: ["sagi", "mascot", "pet"],
|
||||
to: "#pet",
|
||||
},
|
||||
{ section: "appearance", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc", to: "#pet" },
|
||||
{ section: "appearance", titleKey: "sagi.pet.size", to: "#pet" },
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "sagi.pet.choose",
|
||||
keywords: ["codex-pets", "upload", "import"],
|
||||
to: "#pet",
|
||||
},
|
||||
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
|
||||
{ section: "pet", titleKey: "sagi.pet.size" },
|
||||
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
|
||||
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
|
||||
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
|
||||
{ section: "appearance", titleKey: "appearance.lightThemes" },
|
||||
|
||||
@@ -1206,6 +1206,25 @@ export interface CostResult {
|
||||
unpriced_models?: UnpricedModel[];
|
||||
}
|
||||
|
||||
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
||||
export interface ToolQuota {
|
||||
id: "claude" | "codex";
|
||||
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100. */
|
||||
windows: Array<{ label: string; usedPercent: number | null; resetsAt: string | null }>;
|
||||
/** Why there is no figure, e.g. `ELIVEEXPIRED` (Claude's token needs a CLI refresh). */
|
||||
error?: string;
|
||||
/** False for a saved Claude auth profile that is not the live login. */
|
||||
active?: boolean;
|
||||
/** The saved profile's label, when the account has one. */
|
||||
label?: string | null;
|
||||
/** The signed-in account's email, when the tool's login names one. */
|
||||
account?: string | null;
|
||||
/** When the stored access token expires (ISO). */
|
||||
expiresAt?: string | null;
|
||||
/** When the figure was read, if not just now (Codex's journal, an expired profile). */
|
||||
asOf?: string;
|
||||
}
|
||||
|
||||
// ───── Import progress ─────
|
||||
|
||||
/** Payload of the `import.progress` WebSocket message, streamed while a
|
||||
@@ -2716,6 +2735,8 @@ export interface LaneCommit {
|
||||
date: string;
|
||||
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
|
||||
refs: string[];
|
||||
/** Parent shas, first parent first — the history's commit graph. */
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
|
||||
@@ -2743,6 +2764,8 @@ export interface LaneTreeEntry {
|
||||
status?: string;
|
||||
/** A folder with a changed or untracked file anywhere under it. */
|
||||
changed?: true;
|
||||
/** Ignored by git (`.gitignore`) — drawn orange in the Files tree. */
|
||||
ignored?: true;
|
||||
}
|
||||
|
||||
/** One row of a rendered diff — both gutters already resolved server-side. */
|
||||
@@ -2795,6 +2818,8 @@ export type LaneChanges =
|
||||
base: string | null;
|
||||
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
|
||||
baseRef: string | null;
|
||||
/** `range: "branch"`: the merge base the diff is taken from (full sha). */
|
||||
mergeBase?: string | null;
|
||||
/** The commit being shown, for `range: "commit"`; its full sha. */
|
||||
sha: string | null;
|
||||
/** Lines of unchanged context each hunk carries. */
|
||||
|
||||
@@ -39,8 +39,9 @@ import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
|
||||
apiKey: string;
|
||||
behavesAs: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
@@ -50,6 +51,7 @@ const emptyForm = (): FormState => ({
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
@@ -121,6 +123,7 @@ export function AiRouterConfig() {
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
behavesAs: item.behavesAs ?? "",
|
||||
});
|
||||
setFormError(null);
|
||||
setOpen(true);
|
||||
@@ -175,6 +178,7 @@ export function AiRouterConfig() {
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
behavesAs: form.behavesAs.trim(),
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
@@ -370,6 +374,15 @@ export function AiRouterConfig() {
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-behaves-as"
|
||||
label={t("aiRouter.fieldBehavesAs")}
|
||||
hint={t("aiRouter.fieldBehavesAsHint")}
|
||||
value={form.behavesAs}
|
||||
mono
|
||||
placeholder="claude-opus-5-5"
|
||||
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as four pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — language —, `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s (Sagi has its own page, `components/settings/PetSettings.tsx`). Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -205,12 +205,12 @@ function useCountUp(end: number | null, durationMs = 1000) {
|
||||
// ─── Main component ───
|
||||
|
||||
export function Settings({
|
||||
section = "general",
|
||||
section = "notifications",
|
||||
}: {
|
||||
/** Which Settings page this renders (`/settings/<section>`). */
|
||||
section?: "general" | "data" | "about" | "notifications";
|
||||
section?: "data" | "about" | "notifications";
|
||||
} = {}) {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { t } = useTranslation("settings");
|
||||
// Only Data and About show server info; General and Notifications never read it.
|
||||
const needsInfo = section === "data" || section === "about";
|
||||
const [loading, setLoading] = useState(needsInfo);
|
||||
@@ -541,27 +541,6 @@ export function Settings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "general" && (
|
||||
<>
|
||||
<SettingsSection title={t("language.title")} description={t("language.description")}>
|
||||
<SettingsRow
|
||||
label={t("language.label")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: t("language.names.en") },
|
||||
{ value: "vi" as const, label: t("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "notifications" && (
|
||||
<>
|
||||
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* element with that `id` or `data-settings-anchor` and briefly highlights it.
|
||||
* Back returns to the page before Settings when there is one, else /workspace;
|
||||
* an unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* and falls back to General. A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* and falls back to Appearance (which also holds the language and the pet;
|
||||
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* Codex have their own pages, every other installed, enabled catalog tool the
|
||||
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
|
||||
* disabled. A workspace's own settings live on its home, not here.
|
||||
@@ -32,18 +33,12 @@ import {
|
||||
Database,
|
||||
Info,
|
||||
Bot,
|
||||
Boxes,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Orbit,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Puzzle,
|
||||
Router,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
@@ -52,7 +47,6 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { PetSettings } from "../components/settings/PetSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -64,6 +58,7 @@ import {
|
||||
type SettingsEntry,
|
||||
} from "../lib/settingsSearch";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
|
||||
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
|
||||
@@ -80,7 +75,7 @@ interface Section {
|
||||
|
||||
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
|
||||
const LEGACY_TOOL: Record<string, string> = {
|
||||
dashboard: "general",
|
||||
dashboard: "appearance",
|
||||
"claude-code": "agents/claude-code",
|
||||
codex: "agents/codex",
|
||||
"ai-router": "ai-router",
|
||||
@@ -104,13 +99,29 @@ const historyIdx = (): number => {
|
||||
export const backDelta = (current: number, entry: number): number =>
|
||||
-Math.max(1, current - entry + 1);
|
||||
|
||||
/** An unknown section: say so, then show General. */
|
||||
/** Sections folded into Appearance: old links and bookmarks still land. */
|
||||
/** A tool's own mark as a nav icon, one stable component per tool id. */
|
||||
const navIcons = new Map<string, ComponentType<{ className?: string }>>();
|
||||
function toolNavIcon(id: string, label: string) {
|
||||
let Icon = navIcons.get(id);
|
||||
if (!Icon) {
|
||||
Icon = ({ className }) => (
|
||||
<AiToolIcon tool={{ id, label }} className={`${className ?? ""} !bg-transparent !p-0`} />
|
||||
);
|
||||
navIcons.set(id, Icon);
|
||||
}
|
||||
return Icon;
|
||||
}
|
||||
|
||||
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
useEffect(() => {
|
||||
toast.info(t("settingsNav.unknownSection", { section: name }));
|
||||
}, [name, t]);
|
||||
return <Navigate to="/settings/general" replace />;
|
||||
return <Navigate to="/settings/appearance" replace />;
|
||||
}
|
||||
|
||||
export function SettingsHub() {
|
||||
@@ -191,13 +202,6 @@ export function SettingsHub() {
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: "general",
|
||||
group: "general",
|
||||
Icon: SettingsIcon,
|
||||
...s("general"),
|
||||
render: () => <Settings section="general" />,
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
group: "general",
|
||||
@@ -219,13 +223,6 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "pet",
|
||||
group: "general",
|
||||
Icon: PawPrint,
|
||||
...s("pet"),
|
||||
render: () => <PetSettings />,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
@@ -245,7 +242,7 @@ export function SettingsHub() {
|
||||
{
|
||||
id: "agents/claude-code",
|
||||
group: "agents" as const,
|
||||
Icon: Boxes,
|
||||
Icon: toolNavIcon("claude", "Claude Code"),
|
||||
...s("claudeCode"),
|
||||
render: () => <CcConfig />,
|
||||
},
|
||||
@@ -256,7 +253,7 @@ export function SettingsHub() {
|
||||
{
|
||||
id: "agents/codex",
|
||||
group: "agents" as const,
|
||||
Icon: SquareTerminal,
|
||||
Icon: toolNavIcon("codex", "Codex"),
|
||||
...s("codex"),
|
||||
render: () => <CodexConfig />,
|
||||
},
|
||||
@@ -269,7 +266,7 @@ export function SettingsHub() {
|
||||
.map((x) => ({
|
||||
id: `agents/${x.id}`,
|
||||
group: "agents" as const,
|
||||
Icon: x.id === "antigravity" ? Orbit : Puzzle,
|
||||
Icon: toolNavIcon(x.id, x.label),
|
||||
title: x.label,
|
||||
description: t("settingsNav.section.tool.description", { tool: x.label }),
|
||||
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
|
||||
@@ -349,9 +346,10 @@ export function SettingsHub() {
|
||||
? "agents"
|
||||
: tool
|
||||
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
||||
: "general";
|
||||
: "appearance";
|
||||
return <Navigate to={`/settings/${to}`} replace />;
|
||||
}
|
||||
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
|
||||
const active = sections.find((x) => x.id === rest);
|
||||
if (!active) {
|
||||
// A catalog tool page exists only once the tool list has loaded.
|
||||
|
||||
+101
-120
@@ -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
|
||||
@@ -60,8 +61,6 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -84,18 +83,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 +109,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";
|
||||
@@ -113,17 +118,9 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the right panel's activity-bar tab. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
const PANEL_TABS = [
|
||||
{ id: "files", Icon: FolderTree },
|
||||
{ id: "changes", Icon: GitCompare },
|
||||
] as const;
|
||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
@@ -161,20 +158,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 +213,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 +261,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 +284,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 +320,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 +339,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 +362,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 +373,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 +423,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 +444,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}
|
||||
@@ -582,21 +590,6 @@ export function Workspace() {
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||||
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* the panel just forgets its tab between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||
// localStorage and never reaches the server — same rule as the lane strip's
|
||||
// order (client/src/lib/laneOrderStorage.ts).
|
||||
@@ -963,7 +956,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 +985,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}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1218,36 +1206,17 @@ export function Workspace() {
|
||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||
>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||||
control — the same height as the console's tab bar, so the
|
||||
two read as one header strip. */}
|
||||
{/* Header: the title, Analyst, then the resize control — the
|
||||
same height as the console's tab bar, so the two read as
|
||||
one header strip. Files and Changes are ONE panel now; its
|
||||
own mode switcher moves between them. */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-testid={`panel-tab-${id}`}
|
||||
aria-pressed={panelTab === id}
|
||||
// Narrow, a tab switch leaves the open file (and its
|
||||
// edit); at full width the editor stays put.
|
||||
onClick={() =>
|
||||
panelTab !== id &&
|
||||
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
|
||||
}
|
||||
title={tLanes(`panel.${id}`)}
|
||||
aria-label={tLanes(`panel.${id}`)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
panelTab === id
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1305,8 +1274,8 @@ export function Workspace() {
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
tab={panelTab}
|
||||
full={changesFull}
|
||||
wide={changesPreset !== "compact"}
|
||||
onDirtyChange={setChangesDirty}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
@@ -1323,21 +1292,33 @@ 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}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,11 +104,30 @@ describe("AiRouterConfig", () => {
|
||||
// Every tier equals the headline model, so none is sent: the server
|
||||
// fills them, which is what makes one field enough.
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the tiers that differ from the headline model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -66,12 +66,8 @@ const burst = () => {
|
||||
};
|
||||
|
||||
describe("Settings", () => {
|
||||
it("never reads server info on Notifications or General, even on activity", async () => {
|
||||
const { unmount } = render(<Settings section="notifications" />);
|
||||
burst();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
unmount();
|
||||
render(<Settings section="general" />);
|
||||
it("never reads server info on Notifications, even on activity", async () => {
|
||||
render(<Settings section="notifications" />);
|
||||
burst();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock("../Settings", () => ({
|
||||
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../../components/settings/PetSettings", () => ({
|
||||
PetSettings: () => <div data-testid="pet-settings" />,
|
||||
}));
|
||||
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
|
||||
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
|
||||
vi.mock("../AiToolConfig", () => ({
|
||||
@@ -114,7 +117,9 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/settings", "/settings/general"],
|
||||
["/settings", "/settings/appearance"],
|
||||
["/settings/general", "/settings/appearance"],
|
||||
["/settings/pet", "/settings/appearance"],
|
||||
["/settings?tool=codex", "/settings/agents/codex"],
|
||||
["/settings?tool=claude-code", "/settings/agents/claude-code"],
|
||||
["/settings?tool=gemini", "/settings/agents/gemini"],
|
||||
@@ -125,10 +130,12 @@ describe("SettingsHub", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe(to);
|
||||
});
|
||||
|
||||
it("sends an unknown section to General once the lists have loaded", async () => {
|
||||
it("sends an unknown section to Appearance once the lists have loaded", async () => {
|
||||
renderAt("/settings/nope");
|
||||
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/appearance");
|
||||
});
|
||||
|
||||
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
|
||||
@@ -145,8 +152,8 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it("navigates from the mobile select", async () => {
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
|
||||
target: { value: "pricing" },
|
||||
});
|
||||
@@ -156,8 +163,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
await user.type(box, "pricing");
|
||||
const links = within(nav())
|
||||
@@ -177,8 +184,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
expect(box).toHaveFocus();
|
||||
@@ -187,8 +194,8 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
@@ -204,8 +211,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
|
||||
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
|
||||
await user.type(box, "pet");
|
||||
@@ -217,8 +224,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
|
||||
const list = screen.getByRole("list", { name: "Search results" });
|
||||
await user.click(within(list).getAllByRole("link")[0]!);
|
||||
@@ -227,14 +234,16 @@ describe("SettingsHub", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
|
||||
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
|
||||
renderAt("/settings/data");
|
||||
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
|
||||
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
|
||||
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
|
||||
expect(links).not.toContain("General");
|
||||
expect(links).not.toContain("Pet");
|
||||
});
|
||||
|
||||
it("scrolls to and highlights the anchor the URL names", async () => {
|
||||
@@ -258,11 +267,11 @@ describe("SettingsHub", () => {
|
||||
expect(backDelta(0, 2)).toBe(-1);
|
||||
});
|
||||
|
||||
it("toasts when an unknown section falls back to General", async () => {
|
||||
it("toasts when an unknown section falls back to Appearance", async () => {
|
||||
let seen: ToastItem[] = [];
|
||||
const off = subscribeToasts((i) => (seen = i));
|
||||
renderAt("/settings/nope");
|
||||
await screen.findByTestId("settings-general");
|
||||
await screen.findByTestId("pet-settings");
|
||||
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -6806,74 +6832,6 @@ exports[`screen snapshots > Sessions 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`screen snapshots > Settings 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="animate-fade-in space-y-8"
|
||||
>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Language
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
The language of the whole dashboard, remembered in this browser.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Interface language
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<div
|
||||
aria-label="Interface language"
|
||||
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
|
||||
role="radio"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
role="radio"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
Tiếng Việt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`screen snapshots > Settings — about 1`] = `
|
||||
<div>
|
||||
<div
|
||||
@@ -6955,7 +6913,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.3.1 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -9935,7 +9893,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>
|
||||
|
||||
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, act, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
@@ -593,13 +593,9 @@ describe("screen snapshots", () => {
|
||||
it("Run", async () => {
|
||||
await snapshot(<Workspace />, "/run");
|
||||
});
|
||||
it("Settings", async () => {
|
||||
await snapshot(<Settings />, "/settings");
|
||||
});
|
||||
it("Settings — notifications", async () => {
|
||||
await snapshot(<Settings section="notifications" />, "/settings/notifications");
|
||||
});
|
||||
// General split into General / Data / About: keep the moved content covered.
|
||||
it("Settings — data", async () => {
|
||||
await snapshot(<Settings section="data" />, "/settings/data");
|
||||
});
|
||||
@@ -611,29 +607,6 @@ describe("screen snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The language picker moved off the sidebar into Settings → General.
|
||||
describe("Settings — language", () => {
|
||||
it("switches the interface language from General", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings/general"]}>
|
||||
<Settings section="general" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||
await settle();
|
||||
expect(i18n.language).toBe("vi");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
|
||||
// other sub-tabs.
|
||||
|
||||
@@ -49,6 +49,8 @@ const tinted = {
|
||||
danger: tint("--status-danger"),
|
||||
warning: tint("--status-warning"),
|
||||
},
|
||||
diff: { mod: tint("--diff-mod") },
|
||||
kept: tint("--kept"),
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
@@ -104,6 +106,8 @@ export default {
|
||||
danger: "rgb(var(--status-danger) / <alpha-value>)",
|
||||
warning: "rgb(var(--status-warning) / <alpha-value>)",
|
||||
},
|
||||
diff: { mod: "rgb(var(--diff-mod) / <alpha-value>)" },
|
||||
kept: "rgb(var(--kept) / <alpha-value>)",
|
||||
},
|
||||
backgroundColor: tinted,
|
||||
borderColor: tinted,
|
||||
|
||||
+91
-19
@@ -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.
|
||||
|
||||
@@ -458,7 +491,10 @@ One route, three depths — the shapes the Workspace's Changes panel asks for:
|
||||
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
|
||||
untracked files included, `branch` is `<base>...HEAD` for the workspace's
|
||||
`base_branch`, and `commit` shows the single commit named by `sha` (an
|
||||
unresolvable one is `400 EBADREV`, never an empty diff).
|
||||
unresolvable one is `400 EBADREV`, never an empty diff). A `branch` answer also
|
||||
carries `mergeBase` — the full sha of `git merge-base <baseRef> HEAD`, the
|
||||
commit the diff is taken from (`null` on the other ranges or when git cannot
|
||||
find one); the panel's editor reads a file's original text there.
|
||||
|
||||
`context` defaults to **3** and is clamped to **5000** — at the top of that
|
||||
range the diff has effectively become the whole file, which is what "expand
|
||||
@@ -476,6 +512,7 @@ that file.
|
||||
"range": "work",
|
||||
"base": "development",
|
||||
"baseRef": "origin/development",
|
||||
"mergeBase": null,
|
||||
"sha": null,
|
||||
"context": 3,
|
||||
"truncated": false,
|
||||
@@ -526,17 +563,19 @@ 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
|
||||
refs, parents}]}`, newest first in `--date-order` (no parent before its
|
||||
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
|
||||
commits touching that path — relative, no `..` segment, else
|
||||
`400 EBADPATH`. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
@@ -547,6 +586,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
|
||||
@@ -573,7 +619,7 @@ and file explorer. All four are read-only and unguarded, and all four answer
|
||||
ignore (`ls-files --cached --others --exclude-standard`), directories folded
|
||||
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
|
||||
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the workspace.
|
||||
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
|
||||
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Changes panel's Files mode's **Expand all folders** is this one request instead of one per folder.
|
||||
|
||||
#### Read a workspace's own branches
|
||||
|
||||
@@ -614,6 +660,22 @@ reason is gone) is cleared first. A live pane still holding the session is
|
||||
`409 ELIVE`; no `confirm` is `400 ECONFIRM`; a session with no worktree here
|
||||
`400 ENOWORKTREE`. Answers `{removed: true, branch, branchKept}`.
|
||||
|
||||
#### Suggest a commit message
|
||||
|
||||
```http
|
||||
POST /api/lanes/:id/git/commit-message
|
||||
```
|
||||
|
||||
The Changes panel's ✨ button beside the commit box. Runs the user's own
|
||||
`claude` CLI — `claude -p --safe-mode --no-session-persistence --tools "" --model haiku`
|
||||
— on the **staged** diff of the workspace's cwd (or `?session=` worktree), sent on
|
||||
stdin together with the last ten commit subjects so the message follows the
|
||||
repo's style. Only reads: no tool, hook or plugin runs, no transcript is saved,
|
||||
and a `settings.local.json` is never part of the diff. Same-origin, because
|
||||
each call spends the user's Claude quota. Answers `{"message": "..."}`;
|
||||
`400 ENOSTAGED` with nothing staged, `400 ENOTOOL` without `claude` on PATH,
|
||||
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
|
||||
|
||||
#### Act on a workspace's working copy
|
||||
|
||||
```http
|
||||
@@ -994,7 +1056,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 +1172,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,15 +2003,23 @@ 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"
|
||||
```
|
||||
|
||||
#### Get Plan Quota
|
||||
|
||||
```
|
||||
GET /api/pricing/quota[?force=1]
|
||||
```
|
||||
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
|
||||
|
||||
---
|
||||
|
||||
### Notifications
|
||||
@@ -2276,7 +2346,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 +2365,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`.
|
||||
|
||||
@@ -2328,7 +2398,7 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
|
||||
|
||||
```
|
||||
GET /api/ai-routers Every saved profile, the built-in `native` one first
|
||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
|
||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
||||
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||
PATCH /api/ai-routers/:name Update the same fields, all optional
|
||||
DELETE /api/ai-routers/:name Delete one saved profile
|
||||
@@ -2338,6 +2408,8 @@ A **router profile** is an endpoint that stands in front of Claude Code or Codex
|
||||
|
||||
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
|
||||
|
||||
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the router's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the router really sends a name to.
|
||||
|
||||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||||
|
||||
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude workspace's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
|
||||
@@ -2348,7 +2420,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
|
||||
|
||||
**Starting a session on a router.** `POST /api/lanes/:id/start` and `POST /api/run` take `router: "<name>"` (omitted, `""` or `"native"` = the vendor's own API). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADROUTER`, a router on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no router goes back to the one its session last ran on (`dashboard_runs.router`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
|
||||
|
||||
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
|
||||
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
|
||||
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_ROUTER_KEY` through tmux's own `-e`, never as typed text.
|
||||
|
||||
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
|
||||
@@ -2364,11 +2436,11 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||
```
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens **Settings → Pet** (`/settings/pet`, `client/src/components/settings/PetSettings.tsx`): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
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 +2622,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
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user