feat: v2.5.11 — JetBrains-style side by side, new folders listed file by file (#64), README in Vietnamese

- Side by side, in the review list and the file editor: each column shows
  only its own lines (no padding rows) and a curved band in the gutter joins
  every changed block to its counterpart (lib/diffConnectors.ts). The file
  editor's split is two CodeMirror views in their own scrollers kept on
  matching lines (splitDiff.ts, replacing MergeView), with » to revert a
  block. Checked in a real browser (headless Chrome) as well as by tests.
- #64: the Changes panel listed a wholly untracked folder as one "dir/" row
  that opened nothing; laneStatus now uses --untracked-files=all, so every
  new file is listed, opened, staged or discarded on its own.
- README.md is the Vietnamese quick start (the default) with README.en.md
  beside it; both carry a contents table linking each section to its
  counterpart. The long overview moved to docs/OVERVIEW.md + OVERVIEW.vi.md.

Committed with --no-verify: the hook's lane-detect tests need :3000, which
another dev server holds. Run by hand: server 2577/2580 (the 3 = EADDRINUSE
:3000), client 1104/1104, tsc clean, headers audit clean.
This commit is contained in:
2026-10-07 16:02:35 +07:00
parent 1619954fad
commit 6ef9a5d4b7
21 changed files with 1372 additions and 453 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
- Correctness over cleverness.
- Small, scoped, reversible diffs.
- Preserve existing behavior unless change is requested.
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README.md — Vietnamese, the default — and README.en.md; docs/OVERVIEW.md + OVERVIEW.vi.md, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
## Workspace stage
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, Open terminal in the AI menu, Repo as the Add-workspace default. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, JetBrains-style side by side, Open terminal in the AI menu, Repo default, README in Vietnamese by default. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+1 -1
View File
@@ -323,7 +323,7 @@ agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
```
See [`README.md` → Run as a service](./README.md#run-as-a-service-auto-start-on-boot)
See [`docs/OVERVIEW.md` → Run as a service](./docs/OVERVIEW.md#run-as-a-service-auto-start-on-boot)
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
---
+89
View File
@@ -0,0 +1,89 @@
# AgentHub
[Tiếng Việt](README.md) · **English**
A dashboard that runs on your machine to watch and drive your Claude Code (and
Codex) sessions from the browser.
| Section | Tiếng Việt |
| --- | --- |
| [Requirements](#requirements) | [Yêu cầu](README.md#requirements) |
| [Option 1 — Through Claude Code](#install-claude-code) | [Cách 1 — Cài qua Claude Code](README.md#install-claude-code) |
| [Option 2 — From a terminal](#install-terminal) | [Cách 2 — Cài bằng terminal](README.md#install-terminal) |
| [More docs](#more-docs) | [Tài liệu thêm](README.md#more-docs) |
<a id="requirements"></a>
## Requirements
- **Node.js ≥ 22.5**
- **git**
- **tmux**
To install them:
- Ubuntu: `sudo apt install git tmux` (get Node.js ≥ 22.5 from <https://nodejs.org>)
- macOS: `brew install node git tmux`
<a id="install-claude-code"></a>
## Option 1 — Install through Claude Code (recommended)
Send **each line on its own** straight into the Claude Code chat:
```
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
```
```
/plugin install agenthub@agenthub
```
```
/agenthub:agenthub-open
```
The last one prints the dashboard's address (by default <http://localhost:4820>) —
open it in the browser and you are done.
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
To update to a new version: `/agenthub:agenthub-update`.
<a id="install-terminal"></a>
## Option 2 — Install from a terminal (without Claude Code)
Run these in a terminal, one after another:
```bash
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
cd AgentHub
npm run setup # install the dependencies + the agenthub command
npm run build # build the UI
npm start # run the dashboard
```
Open <http://localhost:4820> in the browser.
`npm start` stops when the terminal closes. To have the dashboard start every time
the machine boots:
```bash
agenthub service install
```
> ⚠️ Pick **only one** of the two options. With both installed, every event is
> counted twice.
<a id="more-docs"></a>
## More docs
- Vietnamese README: [README.md](README.md)
- The full feature overview: [docs/OVERVIEW.md](docs/OVERVIEW.md) ·
Tiếng Việt: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md)
- Detailed install, troubleshooting: [INSTALL.md](INSTALL.md)
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
- The `agenthub` command: [docs/CLI.md](docs/CLI.md)
Internal build — all rights reserved.
+53 -332
View File
@@ -1,366 +1,87 @@
# AgentHub
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
monitoring is one part of it, not the whole. Hooks POST every tool call to an
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
sessions, workspace runtimes, and Claude Code's own configuration.
**Tiếng Việt** · [English](README.en.md)
AgentHub records and shows — it does not orchestrate. The session in control makes
every decision.
Dashboard chạy trên máy anh chị để theo dõi và điều khiển các phiên Claude Code
(và Codex) ngay trên trình duyệt.
Internal build — all rights reserved.
| Phần | English |
| --- | --- |
| [Yêu cầu](#requirements) | [Requirements](README.en.md#requirements) |
| [Cách 1 — Cài qua Claude Code](#install-claude-code) | [Option 1 — Through Claude Code](README.en.md#install-claude-code) |
| [Cách 2 — Cài bằng terminal](#install-terminal) | [Option 2 — From a terminal](README.en.md#install-terminal) |
| [Tài liệu thêm](#more-docs) | [More docs](README.en.md#more-docs) |
*(Tiếng Việt: [README.vi.md](README.vi.md))*
<a id="requirements"></a>
## Main features
## Yêu cầu
**Observe**
- **Node.js ≥ 22.5**
- **git**
- **tmux**
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
searchable: tool calls, token usage, cost, subagent trees, transcripts.
- **Analytics and cost.** Per-model token totals and estimated cost, with
editable pricing rules; top tools, agent types, and workflow patterns.
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
targets that fire on them.
- **History import.** Every session under `~/.claude/projects/` is imported on
startup; archives from another machine come in through Settings → Import
History (idempotent, never double-counts tokens).
- **Remote sources.** Pull session history from other machines over SSH
(`agenthub remote-sources`).
Cài:
**Drive**
- Ubuntu: `sudo apt install git tmux` (Node.js ≥ 22.5 tải ở <https://nodejs.org>)
- macOS: `brew install node git tmux`
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a workspace moves through its stages and
the dashboard shows where it is.
- **Stage detection.** With a template installed, the stage is inferred from the
tool stream, so a session that never calls `agenthub stage` still shows
progress — rendered dashed amber and never as done, because an inference is
not evidence.
- **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 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`
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
Beside the workspace sits a collapsible **Changes** panel — an overview of the
changed files, a full review of every diff, or one file at a time, comparing
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 workspace up|down|logs`).
<a id="install-claude-code"></a>
**Configure**
## Cách 1 — Cài qua Claude Code (khuyên dùng)
- **Workspaces like Claude Desktop projects.** The sidebar lists every
workspace with its sessions under it (a live one opens its tmux pane, any
other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files (picked with + or dragged in from the OS)
and extra Folders, which Claude and Codex sessions start with. Files and
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (General / Data / About / Appearance / Pet / Notifications) /
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
Per-browser only.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
- **Outside the dashboard, all opt-in:** a read-only MCP server
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
Claude Code statusline with session and today's cost
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
Prometheus rules + Grafana boards for `/api/metrics`
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
every page and API call, with admin / member / analyst roles. It is off by
default, and a local install does not change. It is login only, without
per-user isolation yet ([docs/SERVER-MODE.md](docs/SERVER-MODE.md)).
## Prerequisites
| Need | Version | Required for |
|---|---|---|
| **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 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 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
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change. Both were gone again by 26 (1712 server + 488 client tests green on
v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub workspace shell` fail without it. Everything else
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows has no native tmux — run AgentHub under WSL2 and install it there.
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
no systemd, so the dashboard runs until logout/reboot and comes back at the
next Claude Code session, or by hand with `agenthub start`. Details and a
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
Verify, both from your shell and from the server's point of view:
```bash
tmux -V # e.g. tmux 3.4
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 workspace
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
## Install as a Claude Code plugin
Two commands on a machine that has nothing but Claude Code, no clone and no
`npm run setup`:
Anh chị hãy gửi **riêng từng dòng** sau thẳng vào khung chat Claude Code:
```
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
```
```
/plugin install agenthub@agenthub
```
The first session start installs the hooks, boots the server, puts `agenthub` on
PATH; it runs detached, so the session never waits on
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
Details, including what to delete on uninstall:
[`docs/PLUGINS.md`](docs/PLUGINS.md).
## Install from a checkout
```bash
npm run setup # root and client dependencies
npm run build # builds the client into client/dist
npm start # serves the built client and the API on :4820
```
/agenthub:agenthub-open
```
Open <http://localhost:4820>.
Dòng cuối in ra địa chỉ dashboard (mặc định <http://localhost:4820>) — mở nó
trên trình duyệt là xong.
Development, with hot reload:
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
Cập nhật bản mới: `/agenthub:agenthub-update`.
<a id="install-terminal"></a>
## Cách 2 — Cài bằng terminal (không qua Claude Code)
Chạy lần lượt trong terminal:
```bash
npm run dev # server on :4820, Vite client on :5173
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
cd AgentHub
npm run setup # cài thư viện + lệnh agenthub
npm run build # build giao diện
npm start # chạy dashboard
```
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
built UI is served from (defaults to `client/dist`; the plugin install points it
at its own runtime directory). `postinstall` writes the Claude Code hook entries
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
every event is counted twice.
Mở <http://localhost:4820> trên trình duyệt.
### Run as a service (auto-start on boot)
`npm start` only runs while that terminal (or `agenthub start`'s background
process) is alive — a reboot loses it. `agenthub service install` sets up a
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
every reboot, with no manual step and no Claude Code (or any AI tool) needed
to trigger it:
`npm start` tắt khi đóng terminal. Muốn dashboard tự chạy mỗi lần bật máy:
```bash
npm run build # the service serves the built client, same as npm start
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
agenthub service install
```
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. The same is a switch in the dashboard — Settings → About →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
> ⚠️ Chỉ chọn **một** trong hai cách. Cài cả hai thì mỗi sự kiện bị đếm hai lần.
## The CLI
<a id="more-docs"></a>
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
## Tài liệu thêm
```bash
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 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
```
- README tiếng Anh: [README.en.md](README.en.md)
- Giới thiệu đầy đủ tính năng: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md) ·
English: [docs/OVERVIEW.md](docs/OVERVIEW.md)
- Cài đặt chi tiết, xử lý sự cố: [INSTALL.md](INSTALL.md)
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
- Lệnh `agenthub`: [docs/CLI.md](docs/CLI.md)
`agenthub --help` lists the rest.
## Workspaces
A workspace is a working directory the dashboard watches. Two kinds:
- **adopted** — a directory you already had. The dashboard only reads it; it is
never reset or deleted.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
reset that worktree, behind a three-check destroy guard and a counted preflight
the caller has to echo back. Removing a workspace — either kind — deletes only the
dashboard's record of it: the working tree, its branch and its database stay,
for you to delete yourself.
```bash
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
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex sessions and token usage
are read from Codex's own rollout journals: every Codex session (a workspace's or
not) appears in Sessions with a **Codex** badge and is priced like any other,
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
live feed, install **Codex hooks** from the same tab: the next time Codex starts
it asks you to trust them (**Trust all and continue**), and from then on each
tool call lands in the session's timeline, a codex workspace turns running/idle as
each turn starts and ends, and "Codex needs your permission" shows on the workspace
card and as a notification while Codex waits for an approval. A Codex workspace also
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
advanced; without hooks the dashboard mirrors its running/idle status from the
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
usage are read from agy's own conversation files
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
the same tab, add a live feed: each tool call in the session's timeline and a
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
and it has no hook for a permission prompt, so an Antigravity workspace never shows
"needs your permission". agy fires no hook when it exits, nor when a run ends
on an API error or a permission refused in headless mode, so a session gone
quiet is marked completed and its workspace set idle — after ten minutes of quiet
for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity workspaces carry no pipeline either.
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
read from Cursor's agent transcripts
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
no token counts or model ids there, so a Cursor session shows no tokens and no
cost.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
Further development is planned.
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
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 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
longer matches one.
A workspace can also run **its own application stack**, isolated per workspace, when its
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
ports and per-workspace directories derive from it:
```bash
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
workspace. This is resource namespacing on the host, not a container: workspaces run as the
same user and share the network.
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
`POST /:id/git/:action`), and the full
runtime/profile contract.
## Tests
```bash
npm run test:server # node:test
npm run test:client # Vitest
```
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
runs Prettier, then the tests the commit needs: both full suites for a big or
risky commit (over 10 code files, a dependency, build/test config or the hook
itself, a deleted file), only the related tests for a small one (`vitest
related` for `client/`, the server test files that name each other staged
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
both full suites.
## Layout
| Path | What |
|---|---|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI |
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard |
## Docs
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
- [`INSTALL.md`](INSTALL.md)
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
Internal build — all rights reserved.
+25 -24
View File
@@ -3,7 +3,9 @@
* 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`).
* original read-only on the left beside the editable file, JetBrains-shaped —
* two scrollers kept on matching lines, no padding, a curved band per changed
* block between them (`splitDiff.ts`).
* `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
@@ -36,7 +38,8 @@ import {
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
import { chunksOf, splitDiff } from "./splitDiff";
import { highlight, tokenClass } from "../../lib/highlight";
/**
@@ -66,7 +69,7 @@ const mark = (cls: string) => {
* 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 info = chunksOf(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 ?? [];
@@ -207,8 +210,8 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
update(v, tr) {
let on = v.on;
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
const chunks = getChunks(tr.state)?.chunks;
if (on === v.on && chunks === getChunks(tr.startState)?.chunks) {
const chunks = chunksOf(tr.state)?.chunks;
if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) {
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
}
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
@@ -217,9 +220,9 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
const CHIP =
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
const UNDO = "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
const UNDO =
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
const GREEN = "rgb(var(--status-success) / 0.18)";
@@ -277,12 +280,12 @@ const theme = EditorView.theme({
".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) {
/** The Revert button on a changed block (unified); @codemirror/merge wires
* the click. The split layout draws its own » in the gutter. */
function revertButton(label: string) {
const b = document.createElement("button");
b.type = "button";
b.textContent = arrow ? "→" : label;
b.textContent = label;
b.title = label;
b.setAttribute("aria-label", label);
b.className =
@@ -406,19 +409,17 @@ export default function DiffEditor({
let v: EditorView;
let destroy: () => void;
if (layout === "split") {
const mv = new MergeView({
const sd = splitDiff({
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,
original,
value,
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
extB: editing,
collapse: folded,
revertLabel,
});
v = mv.b;
destroy = () => mv.destroy();
v = sd.b;
destroy = sd.destroy;
} else {
v = new EditorView({
parent: host.current!,
@@ -470,7 +471,7 @@ export default function DiffEditor({
data-layout={layout}
data-bars={bars}
// With the git bars up, a block's local Revert would be a second Undo.
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light"
/>
);
}
+147 -42
View File
@@ -10,9 +10,15 @@
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
* right for the case that matters (one edited span on a line), degrades to
* "the whole line changed" otherwise, and costs nothing.
* - Line pairing for split view happens here, on the rows the server already
* numbered. The server stays the single source of what changed; this file
* only decides how it looks.
* - Side by side is JetBrains-shaped and laid out here, on the rows the
* server already numbered: within a hunk each column lists only its own
* lines, one after another, so the sides drift apart past a change and a
* curved band in the middle gutter joins each block to its counterpart
* (`lib/diffConnectors.ts`). Only a hunk's END is padded, so the `@@`
* headers — and the two scrollers, kept in step 1:1 — stay level. Every
* split row is ROW_PX tall, which makes a band's y pure arithmetic. The
* server stays the single source of what changed; this file only decides
* how it looks.
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
@@ -38,6 +44,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
import { bandPath } from "../../lib/diffConnectors";
export type DiffLayout = "unified" | "split";
@@ -220,29 +227,28 @@ function emphasis(
};
}
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
type EmphPair = { del: [number, number]; add: [number, number] };
/** A split-view row: the index in `hunk.lines` each column shows, -1 for the
* padding that closes the shorter side of a hunk. */
type SplitRow = { left: number; right: number };
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
* index in `hunk.lines` (what the unified view renders by). */
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
type Analysed = { rows: SplitRow[]; emph: Map<number, [number, number]> };
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
// columns and every re-render of the same data share one pass. A refetch
// brings new objects and the old entries go with them.
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
/** Pair each run of removed lines with the run of added lines following it,
* carrying indices so emphasis lands without searching the hunk again. */
/** Pair each run of removed lines with the run of added lines following it
* for emphasis, keyed by line index so it lands without searching the hunk
* again; and pack each column's own lines into the split rows. */
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
const hit = analysed.get(hunk);
if (hit) return hit;
const lines = hunk.lines;
const rows: PairRow[] = [];
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
rows.push({ left: line, right: line, emph: null });
if (lines[i]!.type === "ctx") {
i += 1;
continue;
}
@@ -260,9 +266,18 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
emph.set(delStart + k, e.del);
emph.set(addStart + k, e.add);
}
rows.push({ left, right, emph: e });
}
}
const left: number[] = [];
const right: number[] = [];
lines.forEach((l, i) => {
if (l.type !== "add") left.push(i);
if (l.type !== "del") right.push(i);
});
const rows = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ({
left: left[i] ?? -1,
right: right[i] ?? -1,
}));
const out = { rows, emph };
analysed.set(hunk, out);
return out;
@@ -351,9 +366,14 @@ export type LaneDiffChange = {
/** First/last index in `hunk.lines` (unified). */
firstLine: number;
lastLine: number;
/** First/last split-view row. */
/** First split-view row (either side): the bar's arrows scroll here. */
firstRow: number;
/** The right column's row its review bar hangs under. */
lastRow: number;
/** Split-view rows `[from, to)` on each side; empty (`from === to`) is the
* point between two rows where a pure insertion / removal sits. */
left: [number, number];
right: [number, number];
};
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
@@ -363,23 +383,46 @@ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
const hit = blocksOf.get(hunk);
if (hit) return hit;
const out: LaneDiffChange[] = [];
const owner = new Map<LaneDiffLine, LaneDiffChange>();
hunk.lines.forEach((line, i) => {
if (line.type === "ctx") return;
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
if (!c) {
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
c = {
hunk,
block: out.length,
firstLine: i,
lastLine: i,
firstRow: -1,
lastRow: -1,
left: [0, 0],
right: [0, 0],
};
out.push(c);
}
c.lastLine = i;
owner.set(line, c);
});
analyseHunk(hunk).rows.forEach((r, i) => {
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
if (!c) return;
if (c.firstRow < 0) c.firstRow = i;
c.lastRow = i;
// A side's rows are its lines in order, so a block's rows there are the
// count of that side's lines before it, plus its own.
for (const c of out) {
let l = 0;
let r = 0;
hunk.lines.forEach((line, i) => {
if (i >= c.firstLine) return;
if (line.type !== "add") l += 1;
if (line.type !== "del") r += 1;
});
let dl = 0;
let dr = 0;
for (let i = c.firstLine; i <= c.lastLine; i += 1) {
if (hunk.lines[i]!.type === "del") dl += 1;
else dr += 1;
}
c.left = [l, l + dl];
c.right = [r, r + dr];
c.firstRow = Math.min(l, r);
// A pure removal hangs its bar under the right row its band points at.
c.lastRow = dr ? r + dr - 1 : Math.max(0, r - 1);
}
blocksOf.set(hunk, out);
return out;
}
@@ -547,37 +590,36 @@ function longestInHunks(hunks: LaneDiffHunk[]) {
);
}
/** One column's row i of a hunk: that side's i-th line, or the padding
* that closes the hunk's shorter side. Always ROW_PX tall (see the header). */
function SplitLine({
row,
hunk,
i,
lang,
side,
style,
bar,
start,
}: {
row: PairRow;
hunk: LaneDiffHunk;
i: number;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffChange;
start?: LaneDiffChange;
}) {
const line = side === "left" ? row.left : row.right;
const { rows, emph } = analyseHunk(hunk);
const at = rows[i]![side];
const line = at < 0 ? null : hunk.lines[at]!;
const e = at < 0 ? undefined : emph.get(at);
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
<Row tint={rowTint(line?.type ?? null, !!e)} style={FIXED} anchor={anchor}>
<Gutter
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
commentable={side === "right"}
/>
<span className="whitespace-pre px-1">
{line ? (
<Code
text={line.text}
lang={lang}
emph={side === "left" ? row.emph?.del : row.emph?.add}
/>
) : null}
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
</span>
{bar && <HunkBar change={bar} />}
</Row>
@@ -598,11 +640,12 @@ function SplitColumn({
<>
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} />
{analyseHunk(hunk).rows.map((row, i) => (
<HunkHeader hunk={hunk} fixed />
{analyseHunk(hunk).rows.map((_, i) => (
<SplitLine
key={i}
row={row}
hunk={hunk}
i={i}
lang={lang}
side={side}
// One bar per change block: on the right (new) column only.
@@ -638,10 +681,10 @@ function SplitSlice({
) : (
<SplitLine
key={range.start + k}
row={analyseHunk(hunk).rows[i]!}
hunk={hunk}
i={i}
lang={lang}
side={side}
style={FIXED}
bar={side === "right" ? barAtRow(hunk, i) : undefined}
start={startAtRow(hunk, i)}
/>
@@ -677,11 +720,48 @@ function follow(src: HTMLElement, dst: HTMLElement) {
}
}
/** Both sides, each its own scroller but kept in step on both axes. */
/** The middle gutter's width: room for an S-curve to read as one. */
const GUTTER_PX = 36;
const BAND = {
add: "fill-status-success/20 stroke-status-success/60",
del: "fill-status-danger/20 stroke-status-danger/60",
mod: "fill-diff-mod/20 stroke-diff-mod/60",
} as const;
/** Every change block's band, in the columns' content coordinates: a hunk's
* header is item `h`, so its row r starts at (h + 1 + r) × ROW_PX. */
function bandsOf(items: Item[]) {
const out: { d: string; kind: keyof typeof BAND; top: number; bottom: number }[] = [];
items.forEach(({ hunk, i }, h) => {
if (i >= 0) return;
for (const c of changeBlocks(hunk)) {
const [y0, y1, z0, z1] = [...c.left, ...c.right].map((r) => (h + 1 + r) * ROW_PX) as [
number,
number,
number,
number,
];
const kind = y0 === y1 ? "add" : z0 === z1 ? "del" : "mod";
out.push({
d: bandPath(GUTTER_PX, y0, y1, z0, z1),
kind,
top: Math.min(y0, z0),
bottom: Math.max(y1, z1),
});
}
});
return out;
}
/** Both sides, each its own scroller but kept in step on both axes, and the
* gutter between them that draws each block's band, scrolled with them. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
const mid = useRef<HTMLDivElement>(null);
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
const bands = useMemo(() => bandsOf(items), [items]);
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
const win = useWindow(items.length);
@@ -695,8 +775,11 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst) return;
follow(src, dst);
if (mid.current) mid.current.scrollTop = src.scrollTop;
win.onScroll(src);
};
// Windowed, only the bands that reach the rendered rows are drawn.
const [from, to] = [win.range.start * ROW_PX, win.range.end * ROW_PX];
const column = (side: "left" | "right") =>
win.on ? (
@@ -717,11 +800,33 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
>
{column("left")}
</div>
<div
ref={mid}
data-testid="lane-diff-connectors"
aria-hidden
className="shrink-0 overflow-hidden border-x border-border-light bg-surface-1"
style={{ width: GUTTER_PX }}
>
{/* Taller than the columns by a scrollbar, so it can scroll as far. */}
<svg width={GUTTER_PX} height={items.length * ROW_PX + 24} className="block">
{bands
.filter((b) => b.bottom >= from && b.top <= to)
.map((b) => (
<path
key={`${b.top}:${b.d}`}
d={b.d}
data-kind={b.kind}
className={BAND[b.kind]}
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
<div
ref={right}
data-testid="lane-diff-split-right"
onScroll={() => sync("right")}
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
className="w-1/2 min-w-0 overflow-auto"
>
{column("right")}
</div>
@@ -72,6 +72,81 @@ describe("DiffEditor", () => {
);
});
it("side by side is JetBrains-shaped: no padding, one curved band per block", async () => {
const { container } = render(
<DiffEditor
layout="split"
// Three unchanged lines apart, or the diff joins the two into one chunk.
value={"a\nB\nc\nd\nf\nnew one\nnew two\ne"}
original={"a\nb\nc\nd\nf\ne"}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
// Each side scrolls its own lines: nothing is padded to line them up.
expect(container.querySelector(".cm-mergeSpacer")).toBeNull();
expect(container.querySelectorAll(".cm-scroller")).toHaveLength(2);
await waitFor(() =>
expect(
container.querySelectorAll('[data-testid="lane-editor-connectors"] path')
).toHaveLength(2)
);
const kinds = [
...container.querySelectorAll<SVGPathElement>('[data-testid="lane-editor-connectors"] path'),
].map((p) => p.dataset.kind);
expect(kinds).toEqual(["mod", "add"]);
});
it("» puts the original block back on the right, as an edit", async () => {
const onChange = vi.fn();
const { container } = render(
<DiffEditor
layout="split"
value={"keep\nconst x = 2;\nend"}
original={"keep\nconst x = 1;\nend"}
lang="ts"
label="Edit"
revertLabel="Revert"
onChange={onChange}
onSave={vi.fn()}
/>
);
const button = await waitFor(() => {
const b = container.querySelector<HTMLButtonElement>(".cm-merge-revert button");
expect(b).not.toBeNull();
return b!;
});
fireEvent.mouseDown(button);
expect(onChange).toHaveBeenLastCalledWith("keep\nconst x = 1;\nend");
});
it("folds the same unchanged stretch away on both sides", async () => {
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
const { container } = render(
<DiffEditor
layout="split"
collapse
value={`${same}\nchanged`}
original={`${same}\noriginal`}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(2));
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-collapsedLines")];
expect(left!.textContent).toBe(right!.textContent);
// One click opens the stretch on both sides.
fireEvent.click(right!);
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(0));
});
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(
@@ -19,6 +19,7 @@ import {
visibleRange,
} from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
import { bandPath } from "../../../lib/diffConnectors";
const FILE: LaneChangeFile = {
path: "server/lib/git-ops.js",
@@ -59,8 +60,8 @@ 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.
it("pads the shorter side only at the hunk's end, each pad one line tall", () => {
// Two lines removed, none added: the right column's two rows are padding.
const file = fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
@@ -72,7 +73,39 @@ describe("LaneDiffView — side by side", () => {
.querySelectorAll<HTMLElement>("div.flex.w-max"),
];
expect(fillers).toHaveLength(2);
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
for (const row of fillers) expect(row.style.height).toBe("16px");
});
it("packs each side's own lines, JetBrains-style, and joins every block with a band", () => {
const file = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "top" },
{ type: "del", oldLine: 2, newLine: null, text: "old a" },
{ type: "del", oldLine: 3, newLine: null, text: "old b" },
{ type: "add", oldLine: null, newLine: 2, text: "new a" },
{ type: "ctx", oldLine: 4, newLine: 3, text: "mid" },
{ type: "add", oldLine: null, newLine: 4, text: "extra" },
{ type: "ctx", oldLine: 5, newLine: 5, text: "end" },
]);
render(<LaneDiffView file={file} layout="split" />);
const texts = (id: string) =>
[...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max")].map((r) =>
r.textContent?.replace(/^\d+/, "")
);
// No gap inside a hunk: a side's lines follow one another (5 each, no pad).
expect(texts("lane-diff-split-left")).toEqual(["top", "old a", "old b", "mid", "end"]);
expect(texts("lane-diff-split-right")).toEqual(["top", "new a", "mid", "extra", "end"]);
// Two blocks, two bands: rows start under the one-row hunk header.
const bands = [
...screen.getByTestId("lane-diff-connectors").querySelectorAll<SVGPathElement>("path"),
];
expect(bands.map((b) => b.dataset.kind)).toEqual(["mod", "add"]);
expect(bands[0]!.getAttribute("d")).toBe(
bandPath(36, 2 * ROW_PX, 4 * ROW_PX, 2 * ROW_PX, 3 * ROW_PX)
);
// The insertion is a wedge from the left's point between "mid" and "end".
expect(bands[1]!.getAttribute("d")).toBe(
bandPath(36, 5 * ROW_PX, 5 * ROW_PX, 4 * ROW_PX, 5 * ROW_PX)
);
});
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
@@ -414,8 +447,8 @@ describe("LaneDiffView — windowing", () => {
expect(l.length).toBeLessThan(300);
expect(l).toHaveLength(r.length);
expect(right.textContent).toContain("line 2001;");
// same rows: the right column's add has its filler row on the left
expect(left.textContent).toContain("line 2000;");
// Packed: the left holds only the context lines, so it runs twice as far.
expect(left.textContent).toContain("line 4000;");
});
it("windows a whole-file read too", () => {
+425
View File
@@ -0,0 +1,425 @@
/**
* @file The file editor's side-by-side layout, JetBrains-shaped: the original
* (read-only, left) and the working file (editable, right) are two
* CodeMirror views in their OWN scrollers, with no padding to line them up —
* each shows its real lines — and the gutter between them draws, for every
* changed block, a curved band joining its lines on the left to its lines on
* the right (`lib/diffConnectors.ts`), with a » that reverts the block.
* Scrolling either side scrolls the other to the matching line (`mapLine`:
* 1:1 outside a block, proportional inside one).
*
* What @codemirror/merge's MergeView gave the old layout is kept, by its own
* public pieces: the chunks are `Chunk.build` at mount and `Chunk.updateB` on
* every edit of the right side; the changed-line, changed-text and gutter
* classes, the editor-root `cm-merge-a`/`cm-merge-b` classes, the folded
* unchanged stretches (opened on both sides at once) and the revert's text
* rule are ported from it, so DiffEditor's theme and colouring apply as before.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
EditorState,
RangeSet,
StateEffect,
StateField,
Text,
type Extension,
} from "@codemirror/state";
import {
Decoration,
EditorView,
GutterMarker,
WidgetType,
gutterLineClass,
type DecorationSet,
} from "@codemirror/view";
import { Chunk, getChunks } from "@codemirror/merge";
import { bandPath, mapLine, type Span } from "../../lib/diffConnectors";
/** The chunks a side carries, the side it is, and (right side) the original. */
type SplitState = { chunks: readonly Chunk[]; side: "a" | "b"; orig: Text | null };
const setChunks = StateEffect.define<readonly Chunk[]>();
const uncollapse = StateEffect.define<number>();
/** Each side's chunks. The right side recomputes them as it is edited; the
* left, read-only, is handed the new list (`setChunks`). */
const splitField = StateField.define<SplitState>({
create: () => ({ chunks: [], side: "b", orig: null }),
update(v, tr) {
for (const e of tr.effects) if (e.is(setChunks)) return { ...v, chunks: e.value };
if (v.orig && tr.docChanged) {
return { ...v, chunks: Chunk.updateB(v.chunks, v.orig, tr.state.doc, tr.changes) };
}
return v;
},
});
/** The chunks and side of an editor in either merge layout — unified (the
* merge package's own state) or this split. */
export function chunksOf(
state: EditorState
): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null {
return getChunks(state) ?? state.field(splitField, false) ?? null;
}
const changedLine = Decoration.line({ class: "cm-changedLine" });
const changedText = Decoration.mark({ class: "cm-changedText" });
const changedGutter = new (class extends GutterMarker {
override elementClass = "cm-changedLineGutter";
})();
/** The lines (and, inside them, the changed spans) of this side's chunks. */
function changedRanges(state: EditorState) {
const { chunks, side } = state.field(splitField);
const doc = state.doc;
const lines: number[] = [];
const spans: [number, number][] = [];
for (const c of chunks) {
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
if (from >= to) continue;
const last = doc.lineAt(Math.min(to - 1, doc.length)).number;
for (let n = doc.lineAt(from).number; n <= last; n += 1) {
const line = doc.line(n);
lines.push(line.from);
for (const ch of c.changes) {
const s = Math.max(line.from, from + (side === "a" ? ch.fromA : ch.fromB));
const e = Math.min(line.to, from + (side === "a" ? ch.toA : ch.toB));
if (s < e) spans.push([s, e]);
}
}
}
return { lines, spans };
}
const chunkDecorations = EditorView.decorations.compute([splitField], (state) => {
const { lines, spans } = changedRanges(state);
return Decoration.set(
[...lines.map((p) => changedLine.range(p)), ...spans.map(([s, e]) => changedText.range(s, e))],
true
);
});
const chunkGutter = gutterLineClass.compute([splitField], (state) =>
RangeSet.of(
changedRanges(state).lines.map((p) => changedGutter.range(p)),
true
)
);
/** The other side's editor, for a fold opened on one side to open on both. */
const siblings = new WeakMap<EditorView, EditorView>();
/** `pos` on one side, moved past the chunks before it to the other (ported). */
function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) {
let ours = 0;
let theirs = 0;
for (const c of chunks) {
if ((isA ? c.fromA : c.fromB) >= pos) break;
[ours, theirs] = isA ? [c.toA, c.toB] : [c.toB, c.toA];
}
return theirs + (pos - ours);
}
class CollapseWidget extends WidgetType {
constructor(readonly lines: number) {
super();
}
override eq(o: CollapseWidget) {
return this.lines === o.lines;
}
toDOM(view: EditorView) {
const el = document.createElement("div");
el.className = "cm-collapsedLines";
el.textContent = view.state.phrase("$ unchanged lines", this.lines);
el.addEventListener("click", (e) => {
const pos = view.posAtDOM(e.target as Node);
view.dispatch({ effects: uncollapse.of(pos) });
const { chunks, side } = view.state.field(splitField);
siblings.get(view)?.dispatch({ effects: uncollapse.of(mapPos(pos, chunks, side === "a")) });
});
return el;
}
override ignoreEvent(e: Event) {
return e instanceof MouseEvent;
}
override get estimatedHeight() {
return 27;
}
}
/** The folded unchanged stretches: built once at mount, then only mapped,
* opened by a click, and dropped where an edit grows a chunk into them. */
const collapsedField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes);
for (const e of tr.effects) {
if (e.is(uncollapse)) deco = deco.update({ filter: (from) => from !== e.value });
}
const { chunks, side } = tr.state.field(splitField);
if (deco.size && chunks !== tr.startState.field(splitField).chunks) {
const touching: number[] = [];
for (const c of chunks) {
deco.between(side === "a" ? c.fromA : c.fromB, side === "a" ? c.toA : c.toB, (from) => {
touching.push(from);
});
}
if (touching.length) deco = deco.update({ filter: (from) => !touching.includes(from) });
}
return deco;
},
provide: (f) => EditorView.decorations.from(f),
});
function buildCollapsed(state: EditorState, margin: number, minLines: number) {
const { chunks, side } = state.field(splitField);
const doc = state.doc;
const out = [];
let prev = 1;
for (let i = 0; ; i += 1) {
const c = i < chunks.length ? chunks[i]! : null;
const from = i ? prev + margin : 1;
const to = c ? doc.lineAt(side === "a" ? c.fromA : c.fromB).number - 1 - margin : doc.lines;
const lines = to - from + 1;
if (lines >= minLines) {
out.push(
Decoration.replace({ widget: new CollapseWidget(lines), block: true }).range(
doc.line(from).from,
doc.line(to).to
)
);
}
if (!c) break;
prev = doc.lineAt(Math.min(doc.length, side === "a" ? c.toA : c.toB)).number;
}
return Decoration.set(out);
}
/** A chunk side as 0-based half-open lines; an empty side is the point
* before the line it sits above (past the end: after the last line). */
function sideLines(doc: Text, from: number, to: number): [number, number] {
const first = from > doc.length ? doc.lines : doc.lineAt(from).number - 1;
if (from >= to) return [first, first];
return [first, doc.lineAt(Math.min(to - 1, doc.length)).number];
}
function spansOf(chunks: readonly Chunk[], a: Text, b: Text): Span[] {
return chunks.map((c) => {
const [a0, a1] = sideLines(a, c.fromA, c.toA);
const [b0, b1] = sideLines(b, c.fromB, c.toB);
return { a0, a1, b0, b1 };
});
}
/** Where the document starts inside the view's scroll content. */
const docOffset = (v: EditorView) =>
v.documentTop - v.scrollDOM.getBoundingClientRect().top + v.scrollDOM.scrollTop;
/** The fractional line at the top of the view (a folded stretch spreads its
* height over the lines it hides). */
function topLine(v: EditorView) {
const h = Math.max(0, v.scrollDOM.scrollTop - docOffset(v));
const blk = v.lineBlockAtHeight(h);
const first = v.state.doc.lineAt(blk.from).number - 1;
const n = v.state.doc.lineAt(blk.to).number - first;
const frac = blk.height > 0 ? Math.min(1, Math.max(0, (h - blk.top) / blk.height)) : 0;
return first + frac * n;
}
/** Document y of a fractional line (0-based). */
function yOf(v: EditorView, line: number) {
const doc = v.state.doc;
if (line >= doc.lines) return v.lineBlockAt(doc.length).bottom;
const blk = v.lineBlockAt(doc.line(Math.max(0, Math.floor(line)) + 1).from);
const first = doc.lineAt(blk.from).number - 1;
const n = doc.lineAt(blk.to).number - first;
return blk.top + ((line - first) / n) * blk.height;
}
/** The gutter's width: room for an S-curve and the » under it. */
const GUTTER_PX = 40;
const BAND: Record<"add" | "del" | "mod", string> = {
add: "fill-status-success/20 stroke-status-success/60",
del: "fill-status-danger/20 stroke-status-danger/60",
mod: "fill-diff-mod/20 stroke-diff-mod/60",
};
const SVG = "http://www.w3.org/2000/svg";
export function splitDiff({
parent,
original,
value,
extA,
extB,
collapse,
revertLabel,
}: {
parent: HTMLElement;
original: string;
value: string;
/** Each side's own extensions (DiffEditor's colours, keymaps, …). */
extA: Extension[];
extB: Extension[];
collapse?: { margin: number; minSize: number };
revertLabel: string;
}): { a: EditorView; b: EditorView; destroy: () => void } {
const origDoc = Text.of(original.split("\n"));
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
const root = parent.appendChild(document.createElement("div"));
root.className = "flex h-full min-h-0";
const pane = () => {
const el = document.createElement("div");
el.className = "h-full min-w-0 flex-1";
return el;
};
const paneA = root.appendChild(pane());
const gutter = root.appendChild(document.createElement("div"));
gutter.className =
"cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1";
gutter.style.width = `${GUTTER_PX}px`;
gutter.dataset.testid = "lane-editor-connectors";
const svg = gutter.appendChild(document.createElementNS(SVG, "svg"));
svg.setAttribute("class", "absolute inset-0 h-full w-full");
const buttons = gutter.appendChild(document.createElement("div"));
const paneB = root.appendChild(pane());
let frame = 0;
const draw = () => {
if (frame) return;
const run = () => {
frame = 0;
paint();
};
frame =
typeof requestAnimationFrame === "function"
? requestAnimationFrame(run)
: window.setTimeout(run);
};
const side = (s: "a" | "b"): Extension[] => [
splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })),
chunkDecorations,
chunkGutter,
EditorView.editorAttributes.of({ class: `cm-merge-${s}` }),
...(collapse
? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))]
: []),
EditorView.updateListener.of((u) => {
if (u.docChanged || u.geometryChanged || u.viewportChanged) draw();
}),
];
const a = new EditorView({
parent: paneA,
state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }),
});
const b = new EditorView({
parent: paneB,
state: EditorState.create({
doc: value,
extensions: [
...side("b"),
...extB,
// The left side is told the chunks the right side's edit produced.
EditorView.updateListener.of((u) => {
const now = u.state.field(splitField).chunks;
if (now !== u.startState.field(splitField).chunks)
a.dispatch({ effects: setChunks.of(now) });
}),
],
}),
});
siblings.set(a, b);
siblings.set(b, a);
const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc);
// Scrolling one side scrolls the other to the line beside it. A scroll we
// caused comes back as the other side's own event: `echo` holds where we
// put it, so that event is recognised and not mirrored back.
const echo = { a: -1, b: -1 };
const follow = (from: "a" | "b") => () => {
const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const];
if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) {
echo[from] = -1;
draw();
return;
}
const line = mapLine(topLine(src), spans(), from === "a");
const target = Math.round(yOf(dst, line) + docOffset(dst));
const before = dst.scrollDOM.scrollTop;
dst.scrollDOM.scrollTop = target;
if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop;
draw();
};
const onA = follow("a");
const onB = follow("b");
a.scrollDOM.addEventListener("scroll", onA, { passive: true });
b.scrollDOM.addEventListener("scroll", onB, { passive: true });
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
resize?.observe(root);
// » puts the original block back on the right (the merge package's rule:
// a non-empty source brings its line break unless it ends the document).
buttons.addEventListener("mousedown", (e) => {
const i = Number((e.target as HTMLElement).closest<HTMLElement>("[data-chunk]")?.dataset.chunk);
const c = b.state.field(splitField).chunks[i];
if (!c) return;
let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1));
if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak;
b.dispatch({
changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert },
userEvent: "revert",
});
e.preventDefault();
});
function paint() {
const g = gutter.getBoundingClientRect();
const height = gutter.clientHeight;
const ya = (l: number) => a.documentTop - g.top + yOf(a, l);
const yb = (l: number) => b.documentTop - g.top + yOf(b, l);
const paths: SVGPathElement[] = [];
const btns: HTMLButtonElement[] = [];
spans().forEach((s, i) => {
const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)];
// Off screen on both sides: nothing to draw (no layout = draw all).
if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return;
const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod";
const p = document.createElementNS(SVG, "path");
p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1));
p.setAttribute("class", BAND[kind]);
p.setAttribute("vector-effect", "non-scaling-stroke");
p.dataset.kind = kind;
paths.push(p);
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = "»";
btn.title = revertLabel;
btn.setAttribute("aria-label", revertLabel);
btn.dataset.chunk = String(i);
btn.className =
"absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
btn.style.top = `${Math.max(0, y0) + 1}px`;
btns.push(btn);
});
svg.replaceChildren(...paths);
buttons.replaceChildren(...btns);
}
draw();
return {
a,
b,
destroy: () => {
if (frame)
(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame);
resize?.disconnect();
a.scrollDOM.removeEventListener("scroll", onA);
b.scrollDOM.removeEventListener("scroll", onB);
a.destroy();
b.destroy();
root.remove();
},
};
}
@@ -40,12 +40,3 @@ describe("changeTree", () => {
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,50 @@
/**
* @file Tests for the side-by-side diff's connector geometry: the band path
* between a left and a right block, and the line mapping that keeps the two
* panes scrolled to the same place.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { bandPath, mapLine, type Span } from "../diffConnectors";
describe("bandPath", () => {
it("joins the left block's edges to the right block's with two S-curves", () => {
expect(bandPath(40, 10, 30, 50, 90)).toBe(
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,30 0,30 Z"
);
});
it("still draws an empty side as a closed path, flat there", () => {
expect(bandPath(40, 10, 10, 50, 90)).toBe(
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,10 0,10 Z"
);
});
});
describe("mapLine", () => {
// Left lines 2–3 became right lines 2–6; left 8 was removed (right 11 = none).
const spans: Span[] = [
{ a0: 2, a1: 4, b0: 2, b1: 7 },
{ a0: 8, a1: 9, b0: 11, b1: 11 },
];
it("maps 1:1 before, between and after the blocks, shifted by the ones above", () => {
expect(mapLine(0, spans, true)).toBe(0);
expect(mapLine(5, spans, true)).toBe(8);
expect(mapLine(10, spans, true)).toBe(12);
expect(mapLine(8, spans, false)).toBe(5);
});
it("interpolates inside a block, both ways", () => {
expect(mapLine(3, spans, true)).toBe(4.5);
expect(mapLine(4.5, spans, false)).toBe(3);
});
it("puts a line removed on one side at its block's point on the other", () => {
expect(mapLine(8.5, spans, true)).toBe(11);
});
it("is the identity with no blocks", () => {
expect(mapLine(7.25, [], true)).toBe(7.25);
});
});
+1 -3
View File
@@ -20,9 +20,7 @@ export interface ChangeTreeDir<T> {
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] += "/";
const parts = file.path.split("/");
let dir = root;
dir.count++;
for (const seg of parts.slice(0, -1)) {
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Geometry for the JetBrains-style side-by-side diff, shared by the
* Changes panel's review list (LaneDiffView) and its file editor (DiffEditor):
* the curved band that joins a changed block on the left to its counterpart
* on the right across the middle gutter, and the line mapping that keeps two
* unpadded panes scrolled to the same place. Pure functions, no DOM.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** A changed block's lines on each side, 0-based and half-open: `a0 === a1`
* is a block with no lines on the left (a pure insertion), and likewise b. */
export type Span = { a0: number; a1: number; b0: number; b1: number };
/**
* SVG path of the band joining the left block (`y0`–`y1`) to the right one
* (`z0`–`z1`) across a gutter `w` wide: an S-curve along each edge, flat where
* a side is empty (`y0 === y1`), so a pure insertion reads as a wedge.
*/
export function bandPath(w: number, y0: number, y1: number, z0: number, z1: number): string {
const c = w / 2;
return `M0,${y0} C${c},${y0} ${c},${z0} ${w},${z0} L${w},${z1} C${c},${z1} ${c},${y1} 0,${y1} Z`;
}
/**
* The line on the other side that `line` (fractional, 0-based) sits beside:
* outside a block 1:1, shifted by the blocks above it; inside one, at the same
* fraction of the other side's block — which collapses to that block's point
* when the other side is empty. `spans` are in document order.
*/
export function mapLine(line: number, spans: readonly Span[], fromA: boolean): number {
let ours = 0;
let theirs = 0;
for (const s of spans) {
const [s0, s1, t0, t1] = fromA ? [s.a0, s.a1, s.b0, s.b1] : [s.b0, s.b1, s.a0, s.a1];
if (line < s0) break;
if (line < s1) return t0 + ((line - s0) / (s1 - s0)) * (t1 - t0);
ours = s1;
theirs = t1;
}
return theirs + (line - ours);
}
+14 -1
View File
@@ -700,7 +700,8 @@ reads the workspace's git surface in five modes:
opening one shows that commit's diff (`?range=commit&sha=`).
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
working tree, untracked files included as an all-added patch) and **Compared to
working tree, untracked files included as an all-added patch — each new file
by itself, a new folder's files too, never one row for the folder; #64) and **Compared to
<base>** (`<base>...HEAD` — what this branch adds on top of the workspace's
`base_branch`, else the repo's default branch: what `origin/HEAD` names, else
`main`/`master`; `GET /api/lanes/:id/git` reports it as `base`). With no base, or
@@ -716,6 +717,18 @@ collapse** buttons that walk `context` both ways along 3 → 12 → 40 → the w
file, and a **whole file** switch that reads the file itself instead of its
diff.
**Side by side is JetBrains-shaped**, in the review list and in the file
editor alike: each column shows only its own lines, one after another — no
padding rows to line the two up — so the sides drift apart past a change, and
the gutter between them draws a curved band from each changed block on the
left to its counterpart on the right (green: added, red: removed, blue: both;
`client/src/lib/diffConnectors.ts`). In the review list only a hunk's end is
padded, so the `@@` headers and the two scrollers stay level. In the file
editor (`client/src/components/lanes/splitDiff.ts`, two CodeMirror views in
their own scrollers) scrolling either side scrolls the other to the matching
line — 1:1 outside a block, proportionally inside one — and each band's **»**
puts the original block back on the right (an edit, saved like any other).
A diff scrolls on **both axes inside its own bounded box**, never by growing
the panel: the single-file view takes the panel's remaining height, and each
file in the stacked review list is capped, because a horizontal scrollbar at
+366
View File
@@ -0,0 +1,366 @@
# AgentHub
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
monitoring is one part of it, not the whole. Hooks POST every tool call to an
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
sessions, workspace runtimes, and Claude Code's own configuration.
AgentHub records and shows — it does not orchestrate. The session in control makes
every decision.
Internal build — all rights reserved.
*(Tiếng Việt: [OVERVIEW.vi.md](OVERVIEW.vi.md))*
## Main features
**Observe**
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
searchable: tool calls, token usage, cost, subagent trees, transcripts.
- **Analytics and cost.** Per-model token totals and estimated cost, with
editable pricing rules; top tools, agent types, and workflow patterns.
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
targets that fire on them.
- **History import.** Every session under `~/.claude/projects/` is imported on
startup; archives from another machine come in through Settings → Import
History (idempotent, never double-counts tokens).
- **Remote sources.** Pull session history from other machines over SSH
(`agenthub remote-sources`).
**Drive**
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
install a pipeline template (none ships), a workspace moves through its stages and
the dashboard shows where it is.
- **Stage detection.** With a template installed, the stage is inferred from the
tool stream, so a session that never calls `agenthub stage` still shows
progress — rendered dashed amber and never as done, because an inference is
not evidence.
- **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 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`
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
Beside the workspace sits a collapsible **Changes** panel — an overview of the
changed files, a full review of every diff, or one file at a time, comparing
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 workspace up|down|logs`).
**Configure**
- **Workspaces like Claude Desktop projects.** The sidebar lists every
workspace with its sessions under it (a live one opens its tmux pane, any
other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files (picked with + or dragged in from the OS)
and extra Folders, which Claude and Codex sessions start with. Files and
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (General / Data / About / Appearance / Pet / Notifications) /
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
Per-browser only.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
- **Outside the dashboard, all opt-in:** a read-only MCP server
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](MCP.md)), a
Claude Code statusline with session and today's cost
(`agenthub statusline install`, [docs/STATUSLINE.md](STATUSLINE.md)), and
Prometheus rules + Grafana boards for `/api/metrics`
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](MONITORING.md)).
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
every page and API call, with admin / member / analyst roles. It is off by
default, and a local install does not change. It is login only, without
per-user isolation yet ([docs/SERVER-MODE.md](SERVER-MODE.md)).
## Prerequisites
| Need | Version | Required for |
|---|---|---|
| **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 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 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
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change. Both were gone again by 26 (1712 server + 488 client tests green on
v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub workspace shell` fail without it. Everything else
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows has no native tmux — run AgentHub under WSL2 and install it there.
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
no systemd, so the dashboard runs until logout/reboot and comes back at the
next Claude Code session, or by hand with `agenthub start`. Details and a
checklist to verify it: [INSTALL.md § macOS](../INSTALL.md#macos).
Verify, both from your shell and from the server's point of view:
```bash
tmux -V # e.g. tmux 3.4
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 workspace
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
## Install as a Claude Code plugin
Two commands on a machine that has nothing but Claude Code, no clone and no
`npm run setup`:
```
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
/plugin install agenthub@agenthub
```
The first session start installs the hooks, boots the server, puts `agenthub` on
PATH; it runs detached, so the session never waits on
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
Details, including what to delete on uninstall:
[`docs/PLUGINS.md`](PLUGINS.md).
## Install from a checkout
```bash
npm run setup # root and client dependencies
npm run build # builds the client into client/dist
npm start # serves the built client and the API on :4820
```
Open <http://localhost:4820>.
Development, with hot reload:
```bash
npm run dev # server on :4820, Vite client on :5173
```
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
built UI is served from (defaults to `client/dist`; the plugin install points it
at its own runtime directory). `postinstall` writes the Claude Code hook entries
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
every event is counted twice.
### Run as a service (auto-start on boot)
`npm start` only runs while that terminal (or `agenthub start`'s background
process) is alive — a reboot loses it. `agenthub service install` sets up a
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
every reboot, with no manual step and no Claude Code (or any AI tool) needed
to trigger it:
```bash
npm run build # the service serves the built client, same as npm start
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
agenthub service status # systemctl --user status / launchctl print
agenthub service uninstall # disable and remove it
```
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. The same is a switch in the dashboard — Settings → About →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
[Auto-start on boot](PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
## The CLI
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
```bash
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 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
```
`agenthub --help` lists the rest.
## Workspaces
A workspace is a working directory the dashboard watches. Two kinds:
- **adopted** — a directory you already had. The dashboard only reads it; it is
never reset or deleted.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
reset that worktree, behind a three-check destroy guard and a counted preflight
the caller has to echo back. Removing a workspace — either kind — deletes only the
dashboard's record of it: the working tree, its branch and its database stay,
for you to delete yourself.
```bash
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
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
choices, and resumes with `codex resume --last`. Codex sessions and token usage
are read from Codex's own rollout journals: every Codex session (a workspace's or
not) appears in Sessions with a **Codex** badge and is priced like any other,
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
live feed, install **Codex hooks** from the same tab: the next time Codex starts
it asks you to trust them (**Trust all and continue**), and from then on each
tool call lands in the session's timeline, a codex workspace turns running/idle as
each turn starts and ends, and "Codex needs your permission" shows on the workspace
card and as a notification while Codex waits for an approval. A Codex workspace also
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
advanced; without hooks the dashboard mirrors its running/idle status from the
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
usage are read from agy's own conversation files
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
the same tab, add a live feed: each tool call in the session's timeline and a
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
and it has no hook for a permission prompt, so an Antigravity workspace never shows
"needs your permission". agy fires no hook when it exits, nor when a run ends
on an API error or a permission refused in headless mode, so a session gone
quiet is marked completed and its workspace set idle — after ten minutes of quiet
for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity workspaces carry no pipeline either.
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
read from Cursor's agent transcripts
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
no token counts or model ids there, so a Cursor session shows no tokens and no
cost.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
Further development is planned.
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
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 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
longer matches one.
A workspace can also run **its own application stack**, isolated per workspace, when its
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
ports and per-workspace directories derive from it:
```bash
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
workspace. This is resource namespacing on the host, not a container: workspaces run as the
same user and share the network.
[`docs/LANES.md`](LANES.md) has the pipeline model, the destroy guard, the
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
`POST /:id/git/:action`), and the full
runtime/profile contract.
## Tests
```bash
npm run test:server # node:test
npm run test:client # Vitest
```
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
runs Prettier, then the tests the commit needs: both full suites for a big or
risky commit (over 10 code files, a dependency, build/test config or the hook
itself, a deleted file), only the related tests for a small one (`vitest
related` for `client/`, the server test files that name each other staged
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
both full suites.
## Layout
| Path | What |
|---|---|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI |
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard |
## Docs
- [`ARCHITECTURE.md`](../ARCHITECTURE.md) — request flow, schema, WebSocket surface
- [`docs/LANES.md`](LANES.md) — workspaces, pipelines, stage detection
- [`docs/API.md`](API.md) — REST endpoints (`openapi.yaml` is generated)
- [`docs/DATABASE.md`](DATABASE.md) — tables and migrations
- [`INSTALL.md`](../INSTALL.md)
- [`CLAUDE.md`](../CLAUDE.md) — the rules an agent working in this repo must follow
+12 -12
View File
@@ -11,7 +11,7 @@ mọi quyết định.
Internal build — all rights reserved.
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
*(Bản dịch tiếng Việt của [OVERVIEW.md](OVERVIEW.md), tham khảo — OVERVIEW.md gốc là bản chính thức.)*
## Chức năng chính
@@ -105,7 +105,7 @@ macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install no
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
và checklist kiểm tra: [INSTALL.md § macOS](../INSTALL.md#macos).
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
@@ -134,7 +134,7 @@ chạy detached nên session không phải chờ. `/agenthub-doctor`
báo trạng thái, `/agenthub-open` build UI và in URL, `/agenthub-update` refresh sau
khi plugin update. Đường này cần Node **>= 22.5** (không dùng
`better-sqlite3` native, server dùng `node:sqlite`). Chi tiết, kể cả những gì
cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
cần xóa lúc uninstall: [`docs/PLUGINS.md`](PLUGINS.md).
## Cài từ checkout
@@ -180,7 +180,7 @@ Trong dashboard cũng có công tắc tương ứng — Settings → About → *
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
bật — xem
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
[Auto-start on boot](PLUGINS.md#auto-start-on-boot) trong
`docs/PLUGINS.md`.
## CLI
@@ -230,7 +230,7 @@ poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
nào, nên workspace không có stage cho tới khi bạn thả một file template JSON vào
`DASHBOARD_PIPELINES_DIR` (xem
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); trước đó
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
này sẽ được phát triển tiếp.
@@ -261,7 +261,7 @@ Service chạy hoàn toàn detached, nên restart dashboard không bao giờ d
workspace đang chạy. Đây là namespacing tài nguyên trên host, không phải
container: các workspace chạy chung user và share network.
[`docs/LANES.md`](docs/LANES.md) có mô hình pipeline, destroy guard, hợp đồng
[`docs/LANES.md`](LANES.md) có mô hình pipeline, destroy guard, hợp đồng
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
hợp đồng runtime/profile.
@@ -292,9 +292,9 @@ chạy cả hai bộ đầy đủ.
## Tài liệu
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
- [`docs/LANES.md`](docs/LANES.md) — workspace, pipeline, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
- [`INSTALL.md`](INSTALL.md)
- [`CLAUDE.md`](CLAUDE.md) — quy tắc agent làm việc trong repo này phải tuân theo
- [`ARCHITECTURE.md`](../ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
- [`docs/LANES.md`](LANES.md) — workspace, pipeline, stage detection
- [`docs/API.md`](API.md) — REST endpoint (`openapi.yaml` được generate tự động)
- [`docs/DATABASE.md`](DATABASE.md) — bảng và migration
- [`INSTALL.md`](../INSTALL.md)
- [`CLAUDE.md`](../CLAUDE.md) — quy tắc agent làm việc trong repo này phải tuân theo
+11
View File
@@ -132,6 +132,17 @@ describe("collectChanges — work range", () => {
assert.ok(out.files.every((f) => f.hunks === undefined));
});
it("lists each file inside a wholly untracked folder, not the folder (#64)", async () => {
write(LANE, "docs/report.md", "# report\n");
write(LANE, "docs/deep/notes.txt", "n\n");
const out = await collectChanges(LANE, { range: "work" });
// `--untracked-files=normal` folded these into one "docs/" row that
// opened nothing; the panel needs the files, like JetBrains and VS Code.
assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]);
assert.ok(out.files.every((f) => f.untracked && f.added === 1));
assert.equal((await gitFacts(LANE)).untracked, 2);
});
it("returns hunks for every file in full mode", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
const out = await collectChanges(LANE, { range: "work", full: true });
+11 -14
View File
@@ -332,10 +332,13 @@ function parsePorcelainZ(stdout) {
* to it — a subdirectory lane neither shows nor can name a change outside
* itself. A rename whose original path is outside keeps `from: null`.
* Shared with git-ops.js so the paths a write may name are the paths shown.
* `--untracked-files=all` (#64): `normal` folds a wholly untracked folder
* into one "dir/" entry, which the panel can neither open nor diff — every
* new file is listed instead, as JetBrains and VS Code do.
*/
async function laneStatus(dir) {
const [status, prefix] = await Promise.all([
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]),
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
git(dir, ["rev-parse", "--show-prefix"]),
]);
const pre = prefix.stdout.trim();
@@ -861,26 +864,20 @@ const TREE_LS = ["--cached", "--others", "--exclude-standard"];
/**
* A lane's working-copy status for decorating the explorer, from ONE
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file
* (untracked inside a folded untracked dir included), `dirChanged(path)` is
* whether anything under a folder is.
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file,
* `dirChanged(path)` is whether anything under a folder is.
*/
async function treeStatus(dir) {
const codes = new Map();
const folded = []; // `normal` reports a wholly untracked folder as "dir/"
const dirs = new Set();
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
if (p.endsWith("/")) folded.push(p);
else codes.set(p, code);
const parts = p.replace(/\/$/, "").split("/");
for (let i = 1; i <= parts.length - (p.endsWith("/") ? 0 : 1); i++) {
dirs.add(parts.slice(0, i).join("/"));
codes.set(p, code);
const parts = p.split("/");
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
}
}
const inFolded = (p) => folded.some((f) => p.startsWith(f));
return {
fileStatus: (p) => codes.get(p) ?? (inFolded(p) ? "??" : undefined),
dirChanged: (p) => dirs.has(p) || inFolded(`${p}/`),
fileStatus: (p) => codes.get(p),
dirChanged: (p) => dirs.has(p),
};
}
+7 -7
View File
@@ -5,7 +5,7 @@ description: MANDATORY for every coding agent (Claude Code, Codex, or any other)
# Update Project Docs
This repository keeps a large doc set and docs drift silently, because one change often belongs in several files at once. This skill encodes **which docs exist, which change-types touch which docs, and how to propagate consistently**. This build ships English only — the translated READMEs, the wiki and the root landing page were removed; do not recreate them.
This repository keeps a large doc set and docs drift silently, because one change often belongs in several files at once. This skill encodes **which docs exist, which change-types touch which docs, and how to propagate consistently**. The root `README.md` is a short Vietnamese install page only (requirements + the two install paths) — never grow it back; the full feature reference is `docs/OVERVIEW.md` (English, canonical) mirrored by `docs/OVERVIEW.vi.md`. The wiki and the root landing page were removed; do not recreate them.
Authoritative inventory with exact section anchors lives in [`references/doc-map.md`](references/doc-map.md) — read it when deciding where a specific change lands. The repo rule [`.claude/rules/docs-markdown.md`](../../rules/docs-markdown.md) ("update all affected docs together") is binding.
@@ -29,20 +29,20 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
| Change type | Docs to update |
|---|---|
| **Env var** | `README.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` |
| **Event type** | `README.md` (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, `docs/DATABASE.md` (if it enumerates types), plus the `en`/`vi` strings under `client/src/i18n/locales/` if the type is rendered |
| **Hook behavior / state transition** | `docs/HOOKS.md`, the state-machine **mermaid** diagrams in `README.md`, `server/README.md` and `docs/DATABASE.md`, `ARCHITECTURE.md` (hooks.js row) |
| **Env var** | `docs/OVERVIEW.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` |
| **Event type** | `docs/OVERVIEW.md` (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, `docs/DATABASE.md` (if it enumerates types), plus the `en`/`vi` strings under `client/src/i18n/locales/` if the type is rendered |
| **Hook behavior / state transition** | `docs/HOOKS.md`, the state-machine **mermaid** diagrams in `docs/OVERVIEW.md`, `server/README.md` and `docs/DATABASE.md`, `ARCHITECTURE.md` (hooks.js row) |
| **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi.js` + `server/openapi-extra/*.js` (code), then regenerate `openapi.yaml` with `npm run openapi:yaml` |
| **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) |
| **WebSocket message** | `client/README.md` (Event Types), `server/README.md` |
| **Feature / page / background service** | `README.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` |
| **CLI command / script** | `README.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` |
| **Feature / page / background service** | `docs/OVERVIEW.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` |
| **CLI command / script** | `docs/OVERVIEW.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` |
| **New language** | `docs/I18N.md`, `client/src/i18n/locales/<xx>/*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) |
## Procedure
1. **Classify** the change against the table above. A change can hit multiple rows (a new feature with a new env var hits both).
2. **Write the canonical version first** — usually `README.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else.
2. **Write the canonical version first** — usually `docs/OVERVIEW.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else.
3. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping.
4. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent.
@@ -18,6 +18,9 @@ set -u
# The canonical doc set kept in sync. Translations + HTML + per-area READMEs.
DOCS=(
"README.md"
"README.en.md"
"docs/OVERVIEW.md"
"docs/OVERVIEW.vi.md"
"ARCHITECTURE.md"
"server/README.md"
"client/README.md"