Compare commits

...

26 Commits

Author SHA1 Message Date
nntrivi2001 6353e88c9e chore(release): bump version to 1.8.4 2026-09-24 09:46:52 +07:00
nntrivi2001 6cf8a82889 feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and
  a named theme remembered per mode — dark: Default, Darcula, Apple Dark;
  light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a
  CSS-variable override block keyed by `data-theme` on <html>; the sidebar
  quick toggle gains System and swaps between the two remembered themes.
- An inline boot script in index.html applies the stored mode and theme
  before first paint, so a reload no longer flashes the default colours.
- Diff +/- rows are easier to tell apart on every theme: a /20 status tint,
  a 3px status bar and a bold coloured marker; the changed-span emphasis no
  longer vanishes on light themes.
- #42: the Add-lane modal (Repo mode / "Current branch") asks
  POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside
  an existing lane resolved to that parent lane with created:false, so the
  modal closed and no new card appeared. When the folder really already has a
  lane, the modal now says which one instead of closing.

Closes #42
2026-09-24 09:44:42 +07:00
nntrivi2001 2ea2184372 chore(release): bump version to 1.8.3 2026-09-23 16:46:40 +07:00
nntrivi2001 aff863c69f fix(plugins): retry claude plugin install|update without -y on an old CLI
An older Claude Code has no -y on `plugin install|update` and exits with
"error: unknown option '-y'", so the dashboard's Update now chain failed at
its plugin_update step (and so did Update all, the plugin buttons and the
superpowers auto-install). Those versions also predate the confirmation
prompt -y answers, so every wrapper now retries the same argv without it
when, and only when, the CLI rejects the flag. /agenthub-update, which the
failure message points users to, falls back the same way.
2026-09-23 16:46:20 +07:00
nntrivi2001 764b967856 fix(dashboard): measure V8 heap against its limit, not its current size
The Health Score's heap component divided heapUsed by heapTotal, which is
only what V8 has grown the heap to so far, so a healthy server read 70-90%
and dragged the score down. GET /api/settings/info now also reports
server.memory.heapLimit (v8 heap_size_limit, the ceiling the process dies
at); the V8 Heap bar and the Health Score divide by it, falling back to
heapTotal against an older server.
2026-09-23 16:46:00 +07:00
nntrivi2001 94ee2dc79c chore(release): bump version to 1.8.2 2026-09-23 13:23:40 +07:00
nntrivi2001 3f50e162e6 feat(run): Enter sends the prompt, and a busy lane attaches instead of failing
- The prompt box sends on Enter (Ctrl/Cmd+Enter still works); Shift+Enter
  inserts a newline, and IME composition (Vietnamese/CJK) never sends.
- Starting or resuming into a lane that still holds a live run answered a
  raw 409 ERUNLIVE; the console now attaches to that run with a notice.
  Checked by error code, since the server's message is prose.
- Active runs gains a two-click Kill for live dashboard-spawned rows, the
  way to free a lane when the previous session really should go.
2026-09-23 13:23:20 +07:00
nntrivi2001 1f5c04e765 feat(ui): warm light theme and pre-mixed status fills
- Light mode moves to Claude's ivory/terracotta family: page #e8e5da, cards
  #f5f3ec (a notch below Claude's ivory, no pure-white step), warm
  borders and card shadow; dark mode is untouched.
- The surface ramp rises in lightness with its index in BOTH themes, so a
  card sits brighter than the page and every hover lifts instead of dims.
- Light --accent #a34a28 (the brand #cc785c is 3.1:1 on ivory); --fg-muted
  held at >=4.5:1; new --syn-* syntax tokens, dark keeps its pastels.
- Status/accent fills (bg-*/N, border-*/N) compile to color-mix against
  --tint-base instead of rgb(/a), so a chip is one colour on every surface.
- Hardcoded dark-only hovers (hover:text-white, hover:bg-white/5) replaced
  by tokens in DateTimePicker and SessionDrillIn.
2026-09-23 13:23:00 +07:00
nntrivi2001 952ee7c724 chore(release): bump version to 1.8.1 2026-09-23 09:18:17 +07:00
nntrivi2001 92cee94431 fix(hooks): store a hook delivered twice only once
A doubled hook registration (plugin + leftover checkout install) POSTs every
event twice with a byte-identical payload, so the timeline showed each row
twice and token/alert/lane side effects ran twice. POST /api/hooks/event now
skips a payload identical to the session's LATEST event from the last 10s
(findRecentDuplicateEvent). Latest-only keeps two legitimately identical
Stop events of consecutive turns, which always have other events between.
2026-09-23 09:18:17 +07:00
nntrivi2001 1b561c22d9 feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
2026-09-23 09:18:17 +07:00
nntrivi2001 32a8887419 chore(release): bump version to 1.7.0 2026-09-21 17:07:08 +07:00
nntrivi2001 f77049c238 feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33):
- `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a
  tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the
  column it was on) had the rest of that draw land at column 0, stamping a
  line's first characters into the left margin of the rows below — the torn
  left edge that only showed up once content scrolled.
- The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery`
  in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl).
  `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every
  attach, and tmux sizes the window to the newest client, so `claude` redrew
  its whole UI at 80 columns until the first `resize` message landed.
- The terminal takes the keyboard on mount, so typing reaches tmux without
  clicking the pane first.
- A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a
  new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console
  returns to the setup form instead of leaving a dead pane behind a
  "New run" button.

Pane environment:
- `server/lib/desktop-env.js` forwards the graphical session's DISPLAY /
  WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from
  `systemctl --user show-environment`, this process's own values winning)
  into the pane. The server normally runs as a systemd user service with none
  of them, so `claude` there could not read the clipboard and answered "no
  image found in clipboard" on every image paste.

Run setup:
- `auto` is a permission mode and is now the default everywhere — allowlists,
  the `--permission-mode` fallback, the select, and the pane's initial state.
- The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`)
  instead of pinned ids. `claude --model` resolves an alias to the latest
  model itself, so a release like Fable 5.1 needs no edit here; pricing is
  unaffected because it keys off the resolved id the transcript reports.
- **Create/update readme file** moved from the Add-lane modal into the lane's
  Tools menu: creating a lane and writing its docs are separate decisions,
  and the second is worth re-running long after the first.
- The Workspace's no-lane state is a real banner; the lane strip (add tile
  included) renders only once a lane exists.

Refs #32, #33.

Tests: npm run test:server (1828 pass), client vitest (558 pass).
2026-09-21 17:03:27 +07:00
nntrivi2001 64017c6ca5 feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
  (`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
  slash command on a claude lane and a stage checklist on a codex one).
  Whatever sits in that box is sent as a real prompt the moment the run
  starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
  the resumed session carries its own transcript, and leftover text in the box
  fired an extra turn into it.

Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
  `useNotifications` and Settings' test button, and it reports where the
  notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
  means it went NOWHERE, so the client falls back to a local notification
  instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
  Workspace selects that lane on arrival.

Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
  `Claude Code-credentials`, where the `claude` CLI actually keeps
  `claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
  The file stays the fallback, is kept in step only when it already exists, and
  `AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).

Tests: npm run test:server (1820 pass), client vitest (553 pass).
2026-09-21 15:15:22 +07:00
nntrivi2001 9156781d75 chore(release): bump version to 1.6.17 2026-09-18 16:53:56 +07:00
nntrivi2001 8d48a038c7 fix(ingest,ui): count each assistant message once, each turn once, and each event once
Three defects that all showed up as "the dashboard's numbers are wrong", plus
the notification and brand work asked for alongside them.

Tokens: Claude Code writes ONE JSONL line per content block of an assistant
message, and every one of those lines repeats the same message-level `usage`.
Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran
1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId`
counts a message once, carried across the transcript cache's incremental chunk
boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser
cannot correct what is already stored, so `setTokenUsage` (exact, baselines
cleared) is added for the deliberate re-parse path and `reconcileTokens` now
covers every session with a transcript on disk rather than imported ones only.

Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on
the transcript's timestamp, so each hook re-inserted the session's whole turn
history — 587k rows, 75% of a real events table, one session holding 68,747
copies of the same turns. The row now carries the transcript timestamp, which
fixes the guard and the feed's ordering together. Same class of bug in the
session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms`
are assigned from the full parse instead of `+=`, since `extract()` returns
whole-transcript totals on every call.

Events: leftover checkout hook entries in `~/.claude/settings.json` double every
event, every subagent row and every token figure. `stripLegacyHooks` has always
removed them — but only inside `bootstrap()`, which `fastPathOk` skips on
exactly the healthy installs where nothing else forces a full pass, so they
survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path.

A one-time repair at the first start after upgrading (`user_version` 3) deletes
the duplicated `TurnDuration` rows and re-parses every transcript with exact
writes. Marker 3 and not 2: an install that ran the earlier pass had its
corrected rows re-inflated by whatever old build was still ingesting hooks.

Router: switching a lane's AI Router while a pane is live only half-applies —
Claude Code re-applies `env` to a running session but keeps the model it
resolved at startup, so the old router's model name reaches the new endpoint and
the next prompt fails with "model not found". The apply route reports
`restart_required` / `run_id`, and the picker offers a Restart that respawns the
pane from the lane id alone (replaying the old `--model` would re-create the
mismatch).

Notifications (#29): the two moments Claude is BLOCKED on the user now have
their own switches and default to on — `onAwaitingInput` (a permission prompt or
a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable
and the second hung off a preference that defaults to off, so the one thing a
user walks away from went unannounced. Prefs shape/defaults/storage move to
`client/src/lib/notifPrefs.ts`; Settings and the hook had private copies.

Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under
it, the collapsed rail shows the mark, and the favicon is vendored to match.
2026-09-18 16:52:05 +07:00
nntrivi2001 59b3ba9e39 chore(release): bump version to 1.6.14 2026-09-18 10:40:24 +07:00
nntrivi2001 eab7c437df feat(skills,pipelines): make the review node review, and make a long run followable and affordable
Closes #24.

**The review node ran nothing.** Both templates declared
`/superpowers:requesting-code-review` — the skill about how to HAND OVER a
review, not one that reviews anything — so `agenthub lanes node review`
performed no review at all. They now declare the bare `/code-review`, Claude
Code's own. It carries no `plugin:` prefix, so `requiredSkillPlugins` skips it
and the doctor's required-plugin row is unchanged (verified against the live
function: still exactly the five superpowers skills the templates name).

**The rest is the brainstorm → implement → review span, made cheaper to run and
harder to lose track of.** `ship-feature` gains four sections it never had:

- **Right-size the run.** The pipeline is not free — every dispatching phase
  costs a model call and minutes, and the fix-loop pays it again each lap. A
  typo does not enter the pipeline at all; a small single-layer change skips the
  plan-debate, independent-test and gate agents and lets the user's acceptance
  be the gate; medium/large runs everything. Sizing is a claim made out loud,
  and a task that grows re-sizes upward.
- **Dispatching a judgement.** The fresh-context/FOREGROUND/never-`fork`/
  hand-only-verifiable-artifacts/one-parseable-line contract, written once
  instead of re-argued in three phases — plus the rule that was missing
  entirely: never dispatch what a command answers. An agent is for judgement,
  not for typing.
- **Resuming after a compaction.** Long runs outlive the context window; the
  lane skill had this and this one did not. Re-derive position from the declared
  stage and the diff, and treat a phase whose evidence you cannot find as one
  that did not happen.
- **A `Leave when:` line on every phase.** Objective exit criteria are what stop
  both the silent skip and the endless polish: intake leaves when the answers
  are in hand, review leaves when the latest pass over the CURRENT diff is
  clean, the gate leaves when verification really ran AND the user said yes.

And, narrower: intake asks only what the repo cannot answer (a question the code
could have answered spends the user's round and earns a worse answer);
implement works the approved plan task by task, re-checks each acceptance
criterion, heartbeats on long work, and treats a plan that turns out wrong as a
return to `plan` rather than a quiet substitution; tests runs the suite the diff
needs rather than both by reflex.
2026-09-18 10:39:01 +07:00
nntrivi2001 e217314248 chore(release): bump version to 1.6.13 2026-09-18 10:22:49 +07:00
nntrivi2001 26b0fe53c7 feat(skills,pipelines): ask the user at the two points being wrong is cheap, and stop paying for reviews the diff does not need
The `default` 8-node pipeline walked past its own intake: the node declared
`agenthub stage intake`, a stage report with no work attached, so "intake" was
a label the run passed through without ever asking a question. It now declares
`/superpowers:brainstorming` (and detects it — that rule moves off `plan`),
and both driving skills make the brainstorm mandatory, asked with
AskUserQuestion and waited on rather than answered on the user's behalf.

Three stops replace three silent slides:

- After the plan is written and adversarially challenged, it goes to the USER
  for approval. Implementation does not start without it, and work that turns
  out to need a different approach comes back for a fresh one.
- Review picks its reviewers from `git diff --name-only`: Claude Code's own
  `code-review` always, a domain reviewer only for a layer the diff actually
  touched, and — new — only once the change is big enough to have seams. Three
  agents re-reading twenty lines buys one opinion at three times the tokens, on
  every fix-loop lap. Review is also a loop now: it ends on a clean pass over
  the current diff, not on one pass having happened.
- The gate asks the user to accept before anything is claimed done (and in the
  lane pipeline, before the PR is published — publishing is outward-facing).
  A NO-GO re-enters by SIZE: a defect goes back to implement, a wrong approach
  back to plan. Nothing carries a NO-GO forward with a note about it.

Also here, from a real stall seen on a lane: `ship-feature` had no waiting rule
at all, and a session at the gate invented one with `ScheduleWakeup` — a
/loop-only primitive that paces nothing here and is refused outright when
`auto` mode cannot reach its safety classifier. The rule the lane skill already
had now exists in both.

Cost, measured in what each agent is actually for: the suite run leaves its
agent (nothing is judged, so nothing is delegated — an agent there buys a
second model's tokens to read output the session can read for free), and
`backend-reviewer` drops from opus to sonnet like its frontend twin; both read
a scoped diff against a fixed checklist, and the senior gate above them is
still opus.

Skill bugs found in the same sweep: doc-map told agents not to recreate the
removed translations and then, three lines later, to mirror every README change
into them; the docs mapping still carried `+VN+CN` and the punctuation left by
their removal; Procedure jumped 2 → 6; Verify required `scripts/doc-coverage.sh`,
which does not exist in this repo.
2026-09-18 10:21:03 +07:00
nntrivi2001 4227a97ab1 chore(release): bump version to 1.6.12 2026-09-18 09:45:50 +07:00
nntrivi2001 0004cf7036 fix(lanes): removing a lane deletes its record only, never the worktree
A lane's `remove` tore down what the lane pointed at: `git worktree remove
--force`, a branch delete, and a drop of the lane's own database. One
mis-click on a dashboard row cost a whole branch's unpushed code — and the
row is the only thing AgentHub actually owns. `remove` now stops the run and
the stack, clears AgentHub's own runtime bookkeeping under
`LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its
branch, git's worktree records and the database stay exactly where they are,
for the user to delete by hand when they mean to.

That also fixes #28. Removal used to route through `assertDestroyable`, whose
third check demands the cwd appear in `git worktree list`; a managed lane
whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not
listed as a worktree in …") and could never be dropped from the dashboard at
all. Removal no longer asks git anything, so no preflight blocker stops it,
and the unpushed-commits force-gate now applies to `reset` alone — nothing
`remove` does can discard a commit.

The UI follows: one label for both lane kinds ("Delete lane record"), copy
that says what survives, and no Force checkbox on removal.
`removeWorktree`/`removeLaneData` remain in place as guarded primitives; no
route calls them any more.
2026-09-18 09:44:17 +07:00
nntrivi2001 75a2de60b2 chore(release): bump version to 1.6.11 2026-09-17 23:20:19 +07:00
nntrivi2001 1aee080fca fix(cc-config): read the live account's quota from the CLI, and ask for every account at once
The quota check failed with `invalid_grant` for the account the user was
logged into - the one profile they would try first. An OAuth refresh token is
single use, so every refresh Claude Code performs on its own account retires
the copy sitting in that account's saved profile: the stored credentials of
the LIVE account are the one set guaranteed to be rejected, however recently
the profile was saved. Confirmed on a real install - the saved profile held a
refresh token three rotations behind `.credentials.json`.

`cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against
the live credentials and, on a hit, the usage call uses what the CLI is
holding. That token is never refreshed and that file is never written:
rotating it would log the user out of their own terminal. An expired live
token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it -
and a genuinely spent refresh token is now reported as ESTALE with what to do
about it, instead of echoing Anthropic's raw 400.

Opening the Profiles modal also asks for every saved account at once, rather
than one button per row: comparing accounts is the only reason the panel
exists. The guard against rewriting credentials over and over is no longer
"one row at a time" but the rules above - the live account costs a plain read,
and any other profile refreshes only once its token has actually expired,
which then leaves it valid for hours of further opens. Each row resolves on
its own, so a superseded account still lets its neighbours report.
2026-09-17 23:17:06 +07:00
nntrivi2001 470f9909b4 chore(release): bump version to 1.6.10 2026-09-17 22:22:15 +07:00
nntrivi2001 5ba9e07d78 feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
2026-09-17 22:20:32 +07:00
145 changed files with 13509 additions and 1816 deletions
+4
View File
@@ -38,5 +38,9 @@
"Bash(claude --version)",
"Bash(gh pr *)"
]
},
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
}
}
+35 -19
View File
@@ -346,8 +346,9 @@ graph TD
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race |
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus `POST /profiles/usage` (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see `cc-usage.js`), plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json` (on **macOS**: out of the login Keychain item `Claude Code-credentials`, which is what the CLI reads there — the file is only a fallback, and `AGENTHUB_CC_KEYCHAIN=0` pins it), and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
| `lib/cc-usage.js` | How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has `subscriptionType`/`rateLimitTier` and nothing else), so it asks `GET https://api.anthropic.com/api/oauth/usage`, the same endpoint the CLI's own `/usage` reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and **nothing here runs on a poll**. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through `cc-profiles.writeProfile()` because a dropped refresh leaves an account that needs a fresh `claude auth login`. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh |
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
@@ -357,7 +358,7 @@ graph TD
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
@@ -367,6 +368,8 @@ graph TD
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. Deliberately the boring half of git — no merge, rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
@@ -467,6 +470,8 @@ graph TD
### Splash & loading UX
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
@@ -600,8 +605,8 @@ graph LR
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
| `/*` | NotFound | None (static 404 page) |
### Activity Feed Interaction Model
@@ -1576,15 +1581,14 @@ last node carries `detect` rules, so a lane can never be auto-declared `done`.
### Codex evidence reliability (`server/lib/codex-nudge.js`)
Auto-advance moves the ring but can never supply evidence; only the session's
own `agenthub stage --evidence` call can, so three mechanisms make that more likely
without weakening declared-wins/inferred-never-`done`:
own `agenthub stage --evidence` call can, so two mechanisms make that more likely
without weakening declared-wins/inferred-never-`done`. (Opening-prompt priming
used to be a third: `LaneConsolePane.tsx` pre-filled the prompt box from the
lane's pipeline — the driving slash command on a Claude lane, a plain-text
stage checklist on a codex one. It is gone: whatever sits in that box is sent
as a real prompt when the run starts, so the box now stays empty and the
prompt is the user's alone.)
- **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex
pane's first prompt is pre-filled with a plain-text checklist of
`agenthub stage <stage> --tool codex` calls, one per node of `Lane.pipeline_nodes`
— the same up-front nudge a Claude lane gets from its pre-filled
`/agenthub:ship-feature-lane` skill command, in plain text since Codex has no
slash commands.
- **`AGENTS.md` stage block** (`server/lib/lane-agents.js`): on a `codex` lane,
`agenthub lanes agents install` writes a marker-delimited
`<!-- agenthub:stage:start -->` section into `<lane cwd>/AGENTS.md` instead of
@@ -1912,7 +1916,7 @@ graph TD
| Component | Responsibility |
| --- | --- |
| **VAPID Pipeline** | Uses the `web-push` library on the server. VAPID keys are auto-generated on first run and persisted to `data/vapid-keys.json` to ensure subscription continuity. |
| **Service Worker** | Located at `client/public/sw.js`. It runs independently of the dashboard tab, listening for `push` events from the browser's push service. It handles `notificationclick` to focus/open the dashboard. |
| **Service Worker** | Located at `client/public/sw.js`. It runs independently of the dashboard tab, listening for `push` events from the browser's push service. It handles `notificationclick` by navigating (`client.navigate`, falling back to `clients.openWindow`) an existing dashboard tab to the notification's `data.url` before focusing it — so a click lands on the lane that raised it, not just whichever tab happened to be open. |
| **macOS Audio Support** | Notifications are explicitly sent with `silent: false` and `sound: "default"`. This overrides macOS behavior that would otherwise suppress audio for web notifications. |
| **Subscription Management** | The dashboard registers the service worker and requests a `PushSubscription`. This subscription (endpoint and keys) is stored in the `push_subscriptions` table, indexed by endpoint. |
| **Event Routing** | When a WebSocket event (e.g., `session_created`) is broadcast, the server also triggers `sendPushToAll()`, which iterates through active subscriptions and sends signed VAPID payloads. |
@@ -1933,6 +1937,16 @@ flowchart TD
style SHOW fill:#10b981,stroke:#34d399,color:#fff
```
### Delivery fallback
`POST /api/push/send` answering `200` means the request was **accepted**, not that anyone was notified: it reports `{ok, native, pushed, failed}`, and a browser that never subscribed (no service worker, a non-secure origin such as `http://<lan-ip>:4820`, a push service the server cannot reach) gets `native:false, pushed:0`. `notify()` in `useNotifications.ts` therefore treats "delivered" as `native || pushed > 0` and otherwise shows the notification locally through the service worker / `Notification` — a fetch that merely resolved is not proof. The fallback is skipped when the push did land, so nothing is shown twice.
Note the browser-side floor this cannot lift: the `Notification` API needs a secure context, so open the dashboard on `http://localhost` (secure by definition) or behind HTTPS — on a bare LAN IP no notification of any kind is possible.
### Click-through to the lane
Every `notify()` call in `useNotifications.ts` resolves a per-event `url` — `/workspace?lane=<id>` — by matching the event's `session_id` against a lane whose `session_id` currently equals it (a lightweight lane cache the hook keeps synced off `lane_update` broadcasts, seeded once via `GET /api/lanes`). `POST /api/push/send`'s optional `url` field carries that path through the VAPID payload as `data.url`; `sw.js`'s `notificationclick` handler reads it back to navigate. The `Workspace` page consumes `?lane=<id>` in a one-shot effect (selects the lane, then strips the param via `replace: true` so it doesn't override a later manual lane switch). No lane match (event predates lane tracking, or the lane was removed) — `url` stays `undefined` and the click just focuses the dashboard, the prior behavior.
### Preference Storage
Notification preferences remain in `localStorage` (`agent-monitor-notifications`) for UI-side filtering, while the actual push delivery is managed by the server-side subscription store.
@@ -1940,10 +1954,12 @@ Notification preferences remain in `localStorage` (`agent-monitor-notifications`
| Preference | UI Key | Logic |
| --- | --- | --- |
| Master Toggle | `enabled` | Controls whether the subscription is active |
| New Session | `onNewSession` | Filtered during push fan-out |
| Session Error | `onSessionError` | Filtered during push fan-out |
| Session Complete | `onSessionComplete` | Filtered during push fan-out |
| Subagent Spawn | `onSubagentSpawn` | Filtered during push fan-out |
| New Session | `onNewSession` | Filtered client-side in `useNotifications` |
| Session Error | `onSessionError` | Filtered client-side in `useNotifications` |
| Awaiting Input | `onAwaitingInput` | `Notification` events — Claude is blocked on the user. Default ON |
| Turn Complete | `onTurnComplete` | `Stop` events — Claude finished a turn. Default ON |
| Session Complete | `onSessionComplete` | `SessionEnd` events. Filtered client-side |
| Subagent Spawn | `onSubagentSpawn` | Filtered client-side in `useNotifications` |
### Service Worker Caching
@@ -2381,7 +2397,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light toggle re-themes the app with one class flip rather than per-component `dark:` variants |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
| **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) |
| **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues |
+4 -2
View File
File diff suppressed because one or more lines are too long
+22 -7
View File
@@ -42,6 +42,13 @@ Internal build — all rights reserved.
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Git, from the dashboard.** Each lane 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 lane 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-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per lane (`agenthub lanes up|down|logs`).
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
@@ -57,6 +64,10 @@ Internal build — all rights reserved.
`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; light:
Default, Apple Light, IntelliJ Light, Canvas. Each mode remembers its own
theme, so the sidebar's quick toggle swaps between the two. Per-browser only.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
@@ -219,9 +230,11 @@ A lane 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 owns
the full lifecycle and may reset or remove it, behind a three-check destroy
guard and a counted preflight the caller has to echo back.
- **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 lane — 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 lanes add --cwd /path/to/repo --title "My feature" # adopt
@@ -240,9 +253,9 @@ also **advances the declared stage** — the one place an inference moves the bl
`current` ring, because a Codex session has no hook and nothing else would ever
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
node stays amber (`passed-no-evidence`), never green, since only the session
itself can say what it finished. Three things help it get there: the run console
pre-fills a codex pane's opening prompt with a stage-declaration checklist built
from its own pipeline nodes, `agenthub lanes agents install` writes the same
itself can say what it finished. Two things help it get there (the run console
no longer pre-fills anything into the prompt box — that box is the user's to
type): `agenthub lanes agents install` writes the stage-declaration
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
a lane's pane if its current stage sits without evidence for more than two
@@ -276,7 +289,9 @@ lane. This is resource namespacing on the host, not a container: lanes run as th
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, `GET /api/lanes/:id/git`, and the full
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
+1 -1
View File
@@ -2,7 +2,7 @@
name: backend-reviewer
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
tools: Read, Grep, Glob, Bash
model: opus
model: sonnet
memory: project
---
+10 -5
View File
@@ -1799,9 +1799,14 @@ async function cmdLanesLifecycle(action, args) {
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
console.log(` database: ${preflight.database} (${fate})`);
}
if (action === "remove") {
console.log(
" Only the dashboard's record is dropped — the working tree, its branch and the database are left for you to delete yourself."
);
}
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
console.log("Warnings:");
for (const warning of preflight.warnings) console.log(` ${warning}`);
@@ -1813,9 +1818,9 @@ async function cmdLanesLifecycle(action, args) {
return;
}
// Only `reset` is impossible for an adopted lane. `remove` IS supported by the
// server: it drops the dashboard's record and never touches the directory
// (server/routes/lanes.js scopes the worktree teardown to managed lanes), so
// refusing it here would make a permitted action unreachable from the CLI.
// server for every lane kind: it drops the dashboard's record and never touches
// the directory, so refusing it here would make a permitted action unreachable
// from the CLI.
if (
action === "reset" &&
Array.isArray(preflight.blocked) &&
@@ -2936,7 +2941,7 @@ const COMMAND_GROUPS = [
[
"lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
],
[
"lanes up|down",
+31 -2
View File
@@ -52,7 +52,17 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
- **React Router 6.28** - Client-side routing with nested layouts
- **WebSocket** - Real-time event streaming from server
- **Lucide Icons** - Modern, consistent icon set
@@ -198,7 +208,7 @@ client/
│ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
│ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── App.tsx # Root component + router setup
@@ -726,6 +736,25 @@ flowchart LR
Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button.
#### Tooltips (`components/Tooltip.tsx`)
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
app the same designed tooltip — there is no wrapper to add at the call site.
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
focus (immediately) it takes the element's native `title`, shows it as an
anchored bubble with an arrow, and puts the attribute back when the bubble
closes. Consequences worth knowing:
- **Write `title="…"` exactly as before.** That is the whole API. A control
keeps its native tooltip, and its accessible name, when JS has not run.
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
in light mode; it flips above/below, clamps to the viewport, and closes on
scroll, resize, pointer-down or `Escape`.
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
expanding a truncated **number** inside a line of text, but draws the same
surface so the two read as one thing.
---
## Utilities
+27 -9
View File
@@ -1,15 +1,33 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<!-- Apply the saved appearance before first paint (same keys as
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
<script>
try {
var m = localStorage.getItem("theme");
var d =
m === "light"
? false
: m === "system"
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
: true;
var r = document.documentElement;
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
r.classList.toggle("dark", d);
r.setAttribute("data-mode", d ? "dark" : "light");
if (t && t !== "default") r.setAttribute("data-theme", t);
} catch (e) {}
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#6366f1" />
<title>Agent Dashboard - SmartGift Solution</title>
<title>AgentHub - SmartGift Solution</title>
<meta
name="description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
@@ -23,8 +41,8 @@
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Agent Dashboard" />
<meta property="og:title" content="Agent Dashboard - SmartGift Solution" />
<meta property="og:site_name" content="AgentHub" />
<meta property="og:title" content="AgentHub - SmartGift Solution" />
<meta
property="og:description"
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
@@ -33,17 +51,17 @@
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Agent Dashboard - SmartGift Solution" />
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
<meta
name="twitter:description"
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
/>
<meta name="twitter:image" content="/og-image.svg" />
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
<meta name="twitter:creator" content="@smartgift" />
<!-- JSON-LD structured data -->
@@ -51,7 +69,7 @@
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Agent Dashboard",
"name": "AgentHub",
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+6 -6
View File
@@ -13,15 +13,15 @@
"categories": ["developer-tools", "productivity"],
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/favicon.ico",
"sizes": "32x32",
"type": "image/x-icon",
"purpose": "any"
},
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/favicon.ico",
"sizes": "32x32",
"type": "image/x-icon",
"purpose": "maskable"
}
]
+7 -1
View File
@@ -87,13 +87,19 @@ self.addEventListener("push", (event) => {
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url;
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
for (const client of windowClients) {
if (client.focus) {
if ("focus" in client) {
// Reuse an already-open tab instead of stacking a new one — that's
// also why `navigate` (same-origin only) beats postMessage: it works
// even if the page hasn't wired up a listener for this.
if (url && "navigate" in client) client.navigate(url).catch(() => {});
return client.focus();
}
}
if (url && clients.openWindow) return clients.openWindow(url);
})
);
});
+4
View File
@@ -60,6 +60,7 @@ import { useCallback } from "react";
import { Layout } from "./components/Layout";
import { DocumentTitle } from "./components/DocumentTitle";
import { SplashScreen } from "./components/SplashScreen";
import { GlobalTooltip } from "./components/Tooltip";
import { Dashboard } from "./pages/Dashboard";
import { History } from "./pages/History";
import { SessionDetail } from "./pages/SessionDetail";
@@ -87,6 +88,9 @@ export default function App() {
return (
<>
<SplashScreen />
{/* One renderer gives every `title` in the app the designed bubble —
see components/Tooltip.tsx for why it is not a wrapper component. */}
<GlobalTooltip />
<BrowserRouter>
<DocumentTitle />
<Routes>
+7 -4
View File
@@ -174,7 +174,7 @@ export function DateTimePicker({
? "bg-accent text-white font-medium"
: isToday
? "bg-surface-3 text-accent font-medium"
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`}
>
{i}
@@ -201,7 +201,10 @@ export function DateTimePicker({
{dateObj ? formatDisplay(dateObj) : placeholder}
</span>
{dateObj && (
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
<X
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
onClick={clearValue}
/>
)}
</button>
@@ -216,7 +219,7 @@ export function DateTimePicker({
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronLeft className="w-4 h-4" />
</button>
@@ -228,7 +231,7 @@ export function DateTimePicker({
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronRight className="w-4 h-4" />
</button>
+33 -16
View File
@@ -54,7 +54,6 @@ import { useTranslation } from "react-i18next";
import {
LayoutDashboard,
FolderOpen,
Activity,
BarChart3,
Workflow,
Play,
@@ -72,6 +71,7 @@ import {
ChevronUp,
ChevronDown,
Sun,
Monitor,
Moon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -92,6 +92,13 @@ const NAV_KEYS = [
{ to: "/settings", icon: Settings, key: "nav:settings" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
// owned by the brand, not vendored here — a machine with no network shows the
// alt text instead.
const BRAND_LOGO =
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
@@ -171,7 +178,7 @@ interface SidebarProps {
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, setTheme, toggleTheme } = useTheme();
const { theme, mode, setMode, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle).
@@ -397,15 +404,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
>
{/* Brand */}
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
<Activity className="w-4 h-4 text-accent" />
</div>
{!collapsed && (
<div className="min-w-0">
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
</div>
<div className="flex flex-col items-center justify-center">
{collapsed ? (
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
) : (
<>
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
{/* Wordmark under the logo: small caps + wide tracking so it reads
as a caption to the logo rather than competing with it. The
hairlines either side keep it centred on a narrow rail. */}
<div className="mt-3 flex w-full items-center gap-2 px-1">
<span className="h-px flex-1 bg-fg-muted/40" />
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
{t("nav:brand")}
</span>
<span className="h-px flex-1 bg-fg-muted/40" />
</div>
</>
)}
</div>
</div>
@@ -531,13 +546,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:theme")}
</p>
<div className="mt-2 grid grid-cols-2 gap-1">
{(["dark", "light"] as const).map((option) => {
const active = option === theme;
<div className="mt-2 grid grid-cols-3 gap-1">
{(["dark", "light", "system"] as const).map((option) => {
const active = option === mode;
return (
<button
key={option}
onClick={() => setTheme(option)}
onClick={() => setMode(option)}
aria-pressed={active}
aria-label={t(`nav:themeNames.${option}`)}
title={t(`nav:themeNames.${option}`)}
@@ -549,8 +564,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
>
{option === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
) : option === "light" ? (
<Sun className="w-3.5 h-3.5" />
) : (
<Monitor className="w-3.5 h-3.5" />
)}
</button>
);
+9 -5
View File
@@ -4,6 +4,13 @@
* by {@link StatCard} for raw metric values and anywhere a compact display needs
* a full-precision expansion without cluttering the layout.
*
* ## Look
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
* are visibly one thing. It stays cursor-following (and not anchored like the
* global one) because it expands a NUMBER inside a line of text, where the
* anchor is a few characters wide and the cursor is the real subject.
*
* ## Portal rendering
* Tooltip content is portaled to `document.body` with `position: fixed` so
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
@@ -56,6 +63,7 @@
import { useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { TOOLTIP_SURFACE } from "./Tooltip";
/** Props for {@link Tip}. */
interface TipProps {
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
{children}
{show &&
createPortal(
<div
ref={tipRef}
style={tipStyle}
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
>
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
{raw}
</div>,
document.body
+200
View File
@@ -0,0 +1,200 @@
/**
* @file Tooltip.tsx
* @description The dashboard's tooltip look, and the one renderer that gives it
* to every control in the app.
*
* ## Why a global renderer instead of a wrapper component
* The UI already labels its icon-only controls with the native `title`
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
* touching every one of those call sites to get a designed bubble, and the
* next `title=` someone writes would be back to the browser's grey box.
* {@link GlobalTooltip} instead listens once at the document: on hover or
* keyboard focus it takes the element's `title`, suppresses the native tooltip
* by removing the attribute for as long as the bubble is up, and restores it
* on the way out. Nothing else in the app has to know it exists, and a control
* keeps its native tooltip (and its accessible name) when JS has not run.
*
* ## The look
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
* hard to read and lands over the thing it describes. It flips above/below and
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
* after a short delay so a cursor crossing a toolbar does not strobe, and is
* built from the theme's own tokens so it is legible in light mode too (the
* old bubble was a hardcoded near-black).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
export const TOOLTIP_SURFACE =
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
"whitespace-pre-wrap break-words";
/** How long the pointer must rest on a control before its tooltip appears. */
const SHOW_DELAY_MS = 350;
/** Gap between the control and the bubble, and the minimum viewport margin. */
const GAP = 8;
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */
const SELECTOR = "[title]:not([data-tip-off])";
type Anchored = { text: string; rect: DOMRect };
type Placed = { left: number; top: number; arrow: number; above: boolean };
/**
* Mount ONCE, near the router root. Every `title` in the app becomes a
* designed tooltip; nothing else has to change.
*/
export function GlobalTooltip() {
const [tip, setTip] = useState<Anchored | null>(null);
const [pos, setPos] = useState<Placed | null>(null);
const bubble = useRef<HTMLDivElement>(null);
// The element whose `title` is currently parked in `data-tip`, so it can be
// put back even if the pointer never leaves it (a route change, a re-render
// that unmounts it, Escape).
const parked = useRef<HTMLElement | null>(null);
const timer = useRef<number | undefined>(undefined);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
const el = parked.current;
if (el) {
const text = el.dataset.tip;
if (text !== undefined) {
el.setAttribute("title", text);
delete el.dataset.tip;
}
parked.current = null;
}
setTip(null);
setPos(null);
}, []);
const open = useCallback(
(el: HTMLElement, delay: number) => {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
timer.current = window.setTimeout(() => {
// Re-read at fire time: the control may have been re-rendered, removed
// or had its title changed while the delay was running.
if (!el.isConnected) return;
const current = (el.getAttribute("title") || "").trim();
if (!current) return;
el.dataset.tip = current;
el.removeAttribute("title");
parked.current = el;
setTip({ text: current, rect: el.getBoundingClientRect() });
}, delay);
},
[restore]
);
useEffect(() => {
const target = (e: Event) => {
const node = e.target;
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
};
const onOver = (e: PointerEvent) => {
const el = target(e);
if (el) open(el, SHOW_DELAY_MS);
};
const onOut = (e: PointerEvent) => {
// Moving between children of the same control must not flicker.
const el = target(e);
const to = e.relatedTarget;
if (el && to instanceof Node && el.contains(to)) return;
if (el || parked.current) restore();
};
// Keyboard users get the same tooltip, immediately — a focus ring that
// reveals nothing is the whole reason icon-only buttons are hard to learn.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (el) open(el, 0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") restore();
};
document.addEventListener("pointerover", onOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("pointerdown", restore);
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", restore);
document.addEventListener("keydown", onKey);
// A bubble anchored to a rect is wrong the moment anything moves.
window.addEventListener("scroll", restore, true);
window.addEventListener("resize", restore);
return () => {
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("pointerdown", restore);
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", restore);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", restore, true);
window.removeEventListener("resize", restore);
restore();
};
}, [open, restore]);
// Placed after the first paint, when the bubble's real size is known —
// measuring it is the only way to flip and clamp without guessing.
useEffect(() => {
if (!tip || !bubble.current) return;
const { width, height } = bubble.current.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const centre = tip.rect.left + tip.rect.width / 2;
let left = centre - width / 2;
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
// Below by default; above only when below would run off the screen AND
// above actually fits, so a control at the very bottom still gets a bubble.
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
setPos({
left,
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
// The arrow points at the control even after the bubble was clamped.
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
above,
});
}, [tip]);
if (!tip) return null;
return createPortal(
<div
ref={bubble}
role="tooltip"
data-testid="global-tooltip"
style={{
position: "fixed",
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
opacity: pos ? 1 : 0,
transition: "opacity 120ms ease-out",
}}
className={TOOLTIP_SURFACE}
>
{tip.text}
{/* Two borders only: a fully bordered diamond draws its other two
edges straight across the bubble it is attached to. */}
<span
aria-hidden
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
pos?.above ? "border-b border-r" : "border-l border-t"
} border-border`}
/>
</div>,
document.body
);
}
export default GlobalTooltip;
@@ -19,14 +19,19 @@ function renderSidebar(wsConnected: boolean, collapsed = false) {
}
describe("Sidebar", () => {
it("should render the brand name", () => {
it("should render the brand logo in place of the text subtitle", () => {
renderSidebar(true);
expect(screen.getByText("Agent Dashboard")).toBeInTheDocument();
// One wordmark in both themes — no light/dark variant.
expect(screen.getByAltText("SmartGift Solution")).toHaveAttribute(
"src",
expect.stringContaining("logo-main.png")
);
});
it("should render the subtitle", () => {
renderSidebar(true);
expect(screen.getByText("SmartGift Solution")).toBeInTheDocument();
it("should render the favicon mark when collapsed", () => {
renderSidebar(true, true);
const mark = screen.getByAltText("SmartGift Solution");
expect(mark).toHaveAttribute("src", "https://smartgift.vn/favicon.ico");
});
it("should render all navigation links", () => {
@@ -0,0 +1,59 @@
/**
* @file Sidebar.theme.test.tsx
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
function renderSidebar(collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
</MemoryRouter>
);
}
describe("Sidebar theme controls", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
renderSidebar();
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking System sets the mode and presses only System", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "System" }));
expect(localStorage.getItem("theme")).toBe("system");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking Light applies light mode to <html>", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "Light" }));
expect(localStorage.getItem("theme")).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
const user = userEvent.setup();
localStorage.setItem("theme", "light");
renderSidebar(true);
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
});
});
@@ -0,0 +1,108 @@
/**
* @file Tooltip.test.tsx
* @description Pins the app-wide tooltip renderer: a control's native `title`
* becomes the designed bubble on hover and on keyboard focus, the native
* tooltip is suppressed only while the bubble is up and the attribute is put
* back afterwards (it is the control's accessible name), and `data-tip-off`
* opts out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
return render(
<>
<GlobalTooltip />
<button title="Refresh the working copy">refresh</button>
{extra}
</>
);
}
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
const settle = async () => {
await act(async () => {
vi.advanceTimersByTime(400);
});
};
describe("GlobalTooltip", () => {
it("shows the bubble after a delay and takes the native title while it is up", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
expect(screen.queryByTestId("global-tooltip")).toBeNull();
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
expect(button).not.toHaveAttribute("title");
});
it("hides and gives the title back on the way out", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
await settle();
unhover(button);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("opens immediately on keyboard focus and closes on Escape", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
fireEvent.focusIn(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
setup(
<button title="Raw value" data-tip-off>
opted out
</button>
);
const opted = screen.getByRole("button", { name: "opted out" });
hover(opted);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(opted).toHaveAttribute("title", "Raw value");
});
it("reads the title through a child element, and ignores an empty one", async () => {
setup(
<button title="">
<span data-testid="blank-child">blank</span>
</button>
);
hover(screen.getByTestId("blank-child"));
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
const button = screen.getByRole("button", { name: "refresh" });
button.innerHTML = "<span data-testid='deep'>refresh</span>";
hover(screen.getByTestId("deep"));
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
});
+330 -228
View File
@@ -14,8 +14,6 @@
* the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the
* auto-setup calls against a cwd that must actually exist on disk first.
* An optional "Create/update readme file" checkbox starts the lane's terminal
* run right after creation with `/sg-all:sg-md-writing` as its first message.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -27,7 +25,19 @@ import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool } from "../../lib/types";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only.
* Nothing is sent from it: an empty branch field means "let the server name
* it", which is how `LANE_BRANCH_PREFIX` keeps working. */
function slugPreview(title: string) {
return title
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -79,11 +89,16 @@ export function AddLaneModal({
onClose,
onAdded,
cwdSuggestions,
prefill,
}: {
open: boolean;
onClose: () => void;
/** Called with the newly created (possibly still-provisioning) lane. */
onAdded: (lane: Lane) => void;
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
* A branch here means the sibling worktree checks out THAT branch, which
* is the existing-branch mode — the reason that mode exists (issue #34). */
prefill?: { sourceRepo?: string; branch?: string } | null;
/** The same suggestion list the Run form already fetched (dashboard cwd,
* home, recently-used paths) — reused rather than fetched a second time. */
cwdSuggestions: CwdSuggestion[];
@@ -94,14 +109,17 @@ export function AddLaneModal({
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
// Two ways to start a worktree lane, and git's own rule decides which is
// possible: "new" forks a branch off `base`, "existing" checks out a branch
// that is already there — which git allows exactly once across all of a
// repo's worktrees, hence the busy markers below.
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
const [writeDocs, setWriteDocs] = useState(false);
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
const [router, setRouter] = useState("native");
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
const [busy, setBusy] = useState(false);
@@ -120,9 +138,9 @@ export function AddLaneModal({
setTitle("");
setBranch("");
setBranches(null);
setWorktrees([]);
setBranchMode("new");
setBase("");
setPipeline("default");
setWriteDocs(false);
setRouter("native");
setRoutersLoaded(false);
setTool("claude");
@@ -132,23 +150,23 @@ export function AddLaneModal({
setSetupResult(null);
};
// The template a lane is created with is the ONLY chance to get it right
// from here: nothing else in the UI can change it afterwards, so a lane
// silently born on `default` renders an 8-node map for a 16-node workflow.
// Fetched on open (templates are file-backed and can change between opens).
// A prefill only ever seeds the fields ON OPEN: the user may then change
// anything, and re-applying it on every render would fight them for the box.
useEffect(() => {
if (!open || !prefill) return;
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
if (prefill.branch) {
setBranch(prefill.branch);
setBranchMode("existing");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// A session that boots on the vendor's API keeps talking to it for its whole
// life, so the router has to be picked here, before the lane exists.
useEffect(() => {
if (!open) return;
let cancelled = false;
api.lanes
.pipelines()
.then((r) => {
if (!cancelled) setPipelines(r.pipelines);
})
.catch(() => {
// Quiet: the select just falls back to the single `default` option
// below, and the lane still gets created.
if (!cancelled) setPipelines([]);
});
api.aiRouters
.list()
.then((r) => {
@@ -184,6 +202,7 @@ export function AddLaneModal({
const path = sourceRepo.trim();
if (!path) {
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(null);
return;
@@ -194,6 +213,7 @@ export function AddLaneModal({
const r = await api.lanes.branches(path);
if (lookedUpFor.current !== path) return; // a newer path superseded this one
setBranches(r.branches);
setWorktrees(r.worktrees || []);
setBase(r.current || r.branches[0] || "");
setBranchesError(null);
} catch {
@@ -201,6 +221,7 @@ export function AddLaneModal({
// Not yet a valid repo path (still being typed, or genuinely wrong) -
// quiet by design, the same way CwdAutocomplete never errors either.
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(t("addLaneNotARepo"));
}
@@ -208,14 +229,6 @@ export function AddLaneModal({
return () => window.clearTimeout(timer);
}, [mode, sourceRepo, t]);
// The checkbox below runs the docs skill by opening the lane's own terminal
// run with the slash command as its first message — a skill only exists
// inside a CLI session, so there is nothing server-side to call instead.
// Claude-only: codex has no slash commands (same reason LaneConsolePane
// prefills prose for a codex lane).
const startDocsRun = (laneId: number, cwd: string) =>
api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" });
// Native is the built-in profile, so this is one call either way — there is
// no "no router" branch to keep in step with the real one.
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
@@ -232,8 +245,9 @@ export function AddLaneModal({
const result = await api.lanes.ensure({
cwd: repo,
title: name || undefined,
pipeline,
tool,
// THIS directory, not a lane on a parent of it (#42).
exact: true,
});
// Report the lane the INSTANT it exists server-side. Everything after
// this is post-creation setup that can fail on its own, and letting one
@@ -241,20 +255,27 @@ export function AddLaneModal({
// the list until some unrelated refetch happened to pick it up.
// Worktree mode below already works this way.
onAdded(result.lane);
// Nothing was created: closing now reads as success while no new card
// appears (#42). Say which lane already owns the folder instead.
if (!result.created) {
setError(
t("addLaneExists", {
id: result.lane.id,
title: result.lane.title || result.lane.cwd,
})
);
setBusy(false);
return;
}
// Router first: a session that booted on the vendor's API would keep
// talking to it for its whole life.
const [routerOutcome, docsOutcome] = await Promise.allSettled([
applyRouter(result.lane.id),
writeDocs && tool === "claude"
? startDocsRun(result.lane.id, result.lane.cwd)
: Promise.resolve(null),
]);
const failed = [routerOutcome, docsOutcome].find((o) => o.status === "rejected");
if (failed && failed.status === "rejected") {
// A session that booted on the vendor's API would keep talking to it
// for its whole life, so the router is applied before anything runs.
try {
await applyRouter(result.lane.id);
} catch (routerErr) {
// The lane exists and is already listed — name what did not finish
// instead of closing on a half-set-up lane.
setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason));
setError(routerErr instanceof Error ? routerErr.message : String(routerErr));
setBusy(false);
return;
}
@@ -267,14 +288,17 @@ export function AddLaneModal({
return;
}
if (!branches || !name || !branch.trim()) return;
// A blank branch in "new" mode is legal and deliberate: the server names
// it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews.
if (!branches || !name || (branchMode === "existing" && !branch.trim())) return;
try {
const result = await api.lanes.worktree({
sourceRepo: repo,
title: name,
base: base || undefined,
branch: branch.trim(),
pipeline,
// An existing branch is checked out as it stands; `base` only means
// something when a branch is being forked.
base: branchMode === "new" ? base || undefined : undefined,
branch: branch.trim() || undefined,
tool,
});
onAdded(result.lane);
@@ -297,7 +321,7 @@ export function AddLaneModal({
api.lanes.mcpSync(result.lane.id),
applyRouter(result.lane.id),
]);
setSetupResult({
const outcome = {
profile:
profileOutcome.status === "fulfilled"
? profileOutcome.value.scaffolded
@@ -308,11 +332,9 @@ export function AddLaneModal({
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
});
if (writeDocs && tool === "claude") {
await startDocsRun(result.lane.id, provisionedLane.cwd);
}
} as const;
const anyFailed = Object.values(outcome).includes("failed");
setSetupResult(outcome);
if (import.meta.env.DEV) {
console.info("[add-lane] auto-setup result:", {
@@ -326,10 +348,17 @@ export function AddLaneModal({
});
}
// Leave the modal open so the setup summary below stays on screen; the
// user dismisses it themselves (Cancel/X) once they've seen it, rather
// than racing a timer that can close before they've looked at it.
// A clean setup has nothing to tell anyone, so the lane is made and the
// dialog is done — leaving it up read as "still working" (the confirm
// button is dead from here, since the lane already exists) with no
// obvious way out. A summary is kept on screen only when a step FAILED,
// and then the confirm button becomes a plain close, because that is the
// one case where the user has something to read before dismissing it.
setBusy(false);
if (!anyFailed) {
reset();
onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
@@ -354,21 +383,33 @@ export function AddLaneModal({
onClose();
}, [onClose, browseOpen]);
// A summary on screen means the lane already exists and a step failed, so
// the confirm button has no second creation to run: it closes instead. That
// is the difference between a dialog that is finished and one that is stuck.
const done = !!setupResult;
const disabled =
!!setupResult ||
!sourceRepo.trim() ||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
!done &&
(!sourceRepo.trim() ||
(mode === "worktree" &&
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
/** branch name -> the worktree directory already holding it. git allows one
* checkout per branch, so these are the branches this form cannot use. */
const busyBranches = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
return (
<ConfirmModal
open={open}
title={t("addLane")}
confirmLabel={t("add")}
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={disabled}
onConfirm={submit}
onConfirm={done ? handleCancel : submit}
onCancel={handleCancel}
>
<div className="space-y-3">
@@ -431,6 +472,37 @@ export function AddLaneModal({
</p>
</div>
{/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */}
{mode === "worktree" && worktrees.length > 0 && (
<div
data-testid="add-lane-worktrees"
className="rounded-md border border-border-light bg-surface-0 p-2"
>
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
{t("addLaneWorktreesTitle", { count: worktrees.length })}
</p>
<ul className="space-y-0.5">
{worktrees.slice(0, 8).map((w) => (
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
<span className="shrink-0 font-mono text-fg-primary">
⑂ {w.branch || t("addLaneWorktreeDetached")}
</span>
<span className="truncate text-fg-muted" title={w.path}>
{w.path}
</span>
</li>
))}
{worktrees.length > 8 && (
<li className="text-[10px] text-fg-muted">
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
</li>
)}
</ul>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
@@ -440,185 +512,215 @@ export function AddLaneModal({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
{t("addLanePipelineLabel")}
</label>
<select
id="add-lane-pipeline"
value={pipeline}
onChange={(e) => setPipeline(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
(p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
)
)}
</select>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active = option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
{tool === "claude" && (
<label
// No tick box: the card itself lights up when selected, the same
// treatment LaneStripCard uses, which makes a second indicator of
// the same state redundant. The input stays and only goes visually
// hidden — it is still what a keyboard toggles and what a screen
// reader announces — so the card carries the focus ring for it.
className={`block cursor-pointer rounded-lg border p-3 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
writeDocs
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="checkbox"
// The wrapping label also holds the hint, so name the input
// itself — otherwise its accessible name is label + hint.
aria-label={t("addLaneWriteDocsLabel")}
checked={writeDocs}
onChange={(e) => setWriteDocs(e.target.checked)}
className="sr-only"
/>
<span
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
>
{t("addLaneWriteDocsLabel")}
</span>
<span className="mt-1 block text-[10px] text-fg-muted">
{t("addLaneWriteDocsHint")}
</span>
</label>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
)}
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
project usually wants the lane to BE the repo, working on
whatever is checked out (`main`) — which is exactly Repo
mode, so this jumps there rather than inventing a second way
to do it. git cannot check `main` out in a second worktree
while the repo itself holds it, so a worktree lane could not
offer this even if it wanted to. */}
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// The placeholder is what the SERVER will name the branch
// if this is left empty, so the default is visible without
// the form having to duplicate LANE_BRANCH_PREFIX.
placeholder={
title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder")
}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
)}
</div>
)}
{/* The router is the one per-lane extra this form still asks for, so it
sits in the form itself — a disclosure around a single field only
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active =
option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
</div>
</div>
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
@@ -123,16 +123,15 @@ export function DestructiveLaneModal({
const blocked = blockingReason(preflight);
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting
// an adopted lane risks nothing on disk, so it needs no force even with unpushed
// commits. Never offered alongside a hard blocker, where confirming is
// for `reset` alone. `remove` forgets the lane's row and deletes nothing on
// disk — no worktree, no branch, no database — so there is no unpushed work it
// could discard. Never offered alongside a hard blocker, where confirming is
// impossible anyway.
const requiresForce =
!blocked &&
preflight !== null &&
preflight.action !== "purge" &&
preflight.blocked.includes("unpushed-commits") &&
(preflight.action === "reset" || preflight.kind === "managed");
preflight.action === "reset" &&
preflight.blocked.includes("unpushed-commits");
const disabled =
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
const facts = preflight ? Object.entries(expectFor(preflight)) : [];
+125 -13
View File
@@ -12,7 +12,8 @@
* second telling of it. The repo
* card also carries the lane's AI Router picker — the Add-lane modal is the
* only other place one is chosen, and a lane outlives its creation. The repo
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
* tooling dropdown (`LaneToolsMenu`, exported from this file — agents
* install / MCP sync / dev-sync check / create-update readme) and the
* remove-lane button live in Workspace's page header / pipeline-picker row
* instead of here, so they stay reachable and un-clipped regardless of this
* card's own scroll area.
@@ -23,6 +24,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
@@ -48,8 +50,11 @@ const LOCKS_REFRESH_MS = 30_000;
* polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders as a card without a git row.
*/
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
@@ -71,9 +76,9 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
alive = false;
clearInterval(timer);
};
}, [laneId]);
}, [laneId, nonce]);
return facts;
return [facts, () => setNonce((n) => n + 1)];
}
/**
@@ -159,7 +164,7 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
const [routers, setRouters] = useState<AiRouter[] | null>(null);
const [current, setCurrent] = useState(lane.router_profile || "native");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
const [note, setNote] = useState<{ ok: boolean; text: string; runId?: string } | null>(null);
// The polled lane list is the source of truth; a switch elsewhere (the lane
// was reset to native by a profile delete, say) has to land here too.
@@ -168,6 +173,16 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
setNote(null);
}, [lane.id, lane.router_profile]);
// One instance of this row serves every lane — it lives in the Workspace's
// lane-detail header, and switching lanes swaps its `lane` prop rather than
// remounting it. So an apply that is still in flight when the user moves on
// would land its rollback and its note on whatever lane is showing by then:
// lane B's failed switch putting lane B's old selection into lane A's picker.
const shownLane = useRef(lane.id);
useEffect(() => {
shownLane.current = lane.id;
}, [lane.id]);
useEffect(() => {
let alive = true;
api.aiRouters
@@ -188,16 +203,51 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
if (!routers || routers.length <= 1) return null;
const change = async (name: string) => {
const laneId = lane.id;
const previous = current;
setCurrent(name);
setBusy(true);
setNote(null);
try {
await api.lanes.routerApply(lane.id, name);
setNote({ ok: true, text: t("router.applied") });
const res = await api.lanes.routerApply(laneId, name);
if (shownLane.current !== laneId) return;
// A live pane adopted the new base URL/key but kept the model names it
// resolved at startup, so it would send the OLD router's model to the NEW
// endpoint and fail with "model not found". Only that case gets the
// warning and the restart button.
setNote(
res.restart_required && res.run_id
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
: { ok: true, text: t("router.applied") }
);
} catch (err) {
if (shownLane.current !== laneId) return;
setCurrent(previous);
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
// `busy` belongs to the control on screen, not to the request, so it is
// cleared either way — a lane switch must never leave the picker locked.
setBusy(false);
}
};
/**
* Kill the pane and open a fresh one for this lane. Started with the lane id
* alone on purpose: the previous run's `--model` flag names a model of the
* router the user just left, so replaying it would re-create the exact
* mismatch this button exists to clear.
*/
const restart = async (runId: string) => {
const laneId = lane.id;
setBusy(true);
try {
await api.run.kill(runId);
await api.run.start({ laneId });
if (shownLane.current !== laneId) return;
setNote({ ok: true, text: t("router.restarted") });
} catch (err) {
if (shownLane.current !== laneId) return;
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
@@ -231,6 +281,19 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
{note.ok ? "✓" : "⚠"}
</span>
)}
{/* The one case with an action attached: restarting the pane is what
actually makes the switch take effect, so it is a button and not
another sentence the user has to act on somewhere else. */}
{note?.runId && (
<button
type="button"
disabled={busy}
onClick={() => void restart(note.runId as string)}
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
{t("router.restart")}
</button>
)}
</label>
);
}
@@ -249,7 +312,8 @@ export function since(sec: number | null): string {
}
/**
* Repo tooling (agents install / MCP sync / dev-sync check) for one lane,
* Repo tooling (agents install / MCP sync / dev-sync check, and the
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
* collapsed into a single dropdown. Lives outside LaneCard so it can be
* placed in the Workspace page header, next to "Add lane" — it polls its
* own runtime state independently of LaneCard's, same pattern as the
@@ -258,14 +322,14 @@ export function since(sec: number | null): string {
export function LaneToolsMenu({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
null
);
const [laneActionBusy, setLaneActionBusy] = useState<
"agents" | "mcp" | "sync-check" | "docs" | null
>(null);
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
const runtime = useLaneRuntime(lane.id, 0);
const runLaneAction = async (
which: "agents" | "mcp" | "sync-check",
which: "agents" | "mcp" | "sync-check" | "docs",
fn: () => Promise<string>
) => {
setLaneActionBusy(which);
@@ -285,6 +349,19 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
});
// A skill only exists inside a CLI session, so there is nothing server-side
// to call: this opens the lane's own terminal run with the slash command as
// its first message. Claude-only — codex has no slash commands.
const handleWriteDocs = () =>
runLaneAction("docs", async () => {
await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: "/sg-all:sg-md-writing",
});
return t("actions.writeDocsResult");
});
const handleMcpSync = () =>
runLaneAction("mcp", async () => {
const result = await api.lanes.mcpSync(lane.id);
@@ -340,6 +417,22 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
>
{t("actions.agentsInstall")}
</button>
{lane.tool !== "codex" && (
<button
type="button"
role="menuitem"
data-testid="lane-write-docs"
title={t("actions.writeDocsHint")}
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleWriteDocs();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.writeDocs")}
</button>
)}
<button
type="button"
role="menuitem"
@@ -520,6 +613,7 @@ export default function LaneCard({
onAction,
childWorktrees,
onSelectLane,
onNewLaneFromRepo,
}: {
lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void;
@@ -529,6 +623,9 @@ export default function LaneCard({
childWorktrees?: Lane[];
/** Jumps the Workspace page's selection to another lane's card. */
onSelectLane?: (id: number) => void;
/** Opens the Add-lane flow prefilled with this lane's repo, for a sibling
* worktree on `branch`. Omitted where that flow is not reachable. */
onNewLaneFromRepo?: (repo: string, branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
@@ -550,7 +647,7 @@ export default function LaneCard({
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
const git = useLaneGitFacts(lane.id);
const [git, refreshGit] = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
@@ -720,6 +817,21 @@ export default function LaneCard({
)}
</div>
)}
{git?.available && (
<LaneGitBar
laneId={lane.id}
git={git}
onChanged={refreshGit}
/* A sibling worktree forks from the lane's SOURCE repo when it
has one (this lane is itself a worktree of it); an adopted lane
is its own repo. */
onNewLane={
onNewLaneFromRepo
? (branch) => onNewLaneFromRepo(lane.source_repo || lane.cwd, branch)
: undefined
}
/>
)}
{/* The lane's own recorded branch, shown only when git could not be
read — otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && (
@@ -0,0 +1,905 @@
/**
* @file The Changes panel: the lane's code beside its console, in the five
* shapes a review actually needs —
*
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
* `commit` (one commit, reached from history).
*
* Only the overview polls. Review, file, history and browse are a deliberate
* read of a frozen moment — re-fetching under the reader's scroll position is
* worse than a slightly stale view, and those are the expensive shapes.
*
* Rendering lives in LaneDiffView.tsx. Every write goes through
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
* before the server is told `confirm: true` — it is the one action in this
* panel git cannot undo.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
Columns2,
FileText,
FoldVertical,
Folder,
Minus,
Plus,
RefreshCw,
Rows3,
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import type {
LaneChangeFile,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneTreeEntry,
} from "../../lib/types";
/** How often the overview re-reads. Summary only — no patch text. */
const OVERVIEW_REFRESH_MS = 15_000;
/** Context steps the expand button walks through, then the whole file. */
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
type Mode = "overview" | "review" | "file" | "history" | "browse";
/** Colour per porcelain status letter, matching LaneCard's file panel. */
function statusClass(status: string, untracked: boolean) {
if (untracked) return "text-status-success";
const code = status.trim()[0];
if (code === "A") return "text-status-success";
if (code === "D") return "text-status-danger";
if (code === "R") return "text-accent";
return "text-status-warning";
}
/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file
* has no line delta at all, so it says `bin` rather than leaving the row's
* right edge blank, which reads as "changed by nothing". */
function Delta({
added,
deleted,
binary,
}: {
added: number | null;
deleted: number | null;
binary?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
if (binary)
return <span className="shrink-0 font-mono text-[10px] text-fg-muted">{t("changes.bin")}</span>;
return (
<span className="shrink-0 font-mono text-[10px]">
{added ? <span className="text-status-success">+{added}</span> : null}
{added && deleted ? " " : null}
{deleted ? <span className="text-status-danger">−{deleted}</span> : null}
</span>
);
}
/**
* One folder level of the browse tree, rendered in place and recursing into
* every expanded child — so opening a nested file no longer means navigating
* away from its siblings.
*/
function TreeNodes({
path,
depth,
children,
expanded,
onToggleDir,
onOpenFile,
}: {
path: string;
depth: number;
children: Record<string, LaneTreeEntry[]>;
expanded: Set<string>;
onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void;
}) {
const entries = children[path];
if (!entries) return null;
return (
<>
{entries.map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
className="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{e.dir ? (
expanded.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)
) : (
<span className="w-3 shrink-0" />
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-accent" />
) : (
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span className="min-w-0 flex-1 truncate">{e.name}</span>
</button>
{e.dir && expanded.has(e.path) && (
<ul>
<TreeNodes
path={e.path}
depth={depth + 1}
children={children}
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
/>
</ul>
)}
</li>
))}
</>
);
}
function IconButton({
label,
onClick,
disabled,
danger,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={`shrink-0 rounded p-0.5 disabled:opacity-40 ${
danger ? "text-fg-muted hover:text-status-danger" : "text-fg-muted hover:text-fg-primary"
}`}
>
{children}
</button>
);
}
export function LaneChangesPanel({ laneId }: { laneId: number }) {
const { t } = useTranslation(["lanes"]);
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
const [whole, setWhole] = useState(false);
const [data, setData] = useState<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [hits, setHits] = useState<string[] | null>(null);
const [text, setText] = useState<{ path: string; lines: string[]; truncated: boolean } | null>(
null
);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
// Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Where opening a file came FROM: its mode and how far the list was scrolled.
// Reviewing a change-set is a walk down a long list, and landing back at the
// top of it after every file is the whole cost of opening one.
const scroller = useRef<HTMLDivElement>(null);
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await api.lanes.changes(laneId, {
range,
...(range === "commit" && sha ? { sha } : {}),
...(mode === "file" && openFile ? { path: openFile, context } : {}),
...(mode === "review" ? { full: true } : {}),
});
setData(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [laneId, range, sha, mode, openFile, context]);
useEffect(() => {
if (mode === "history" || mode === "browse") return;
void load();
// Only the cheap shape polls; see the file header.
if (mode !== "overview") return;
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
return () => window.clearInterval(timer);
}, [load, mode]);
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
useEffect(() => {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.then((r) => setCommits(r.commits))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId]);
useEffect(() => {
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
void api.lanes
.gitTree(laneId, "")
.then((r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` is the cache this fills — reading it as a dep would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, laneId, query]);
// A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.then((r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries })))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const [treeBusy, setTreeBusy] = useState(false);
// Fetches whatever level isn't cached yet, depth-first, so "expand all"
// works even on a tree nothing has been opened in.
const expandAllDirs = async () => {
setTreeBusy(true);
const cache = { ...treeChildren };
const toExpand = new Set(expandedDirs);
const walk = async (path: string) => {
let entries = cache[path];
if (!entries) {
entries = (await api.lanes.gitTree(laneId, path)).entries;
cache[path] = entries;
}
const dirs = entries.filter((e) => e.dir);
for (const d of dirs) toExpand.add(d.path);
await Promise.all(dirs.map((d) => walk(d.path)));
};
try {
await walk("");
setTreeChildren(cache);
setExpandedDirs(toExpand);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setTreeBusy(false);
}
};
useEffect(() => {
const q = query.trim();
if (mode !== "browse" || !q) {
setHits(null);
return;
}
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(laneId, q)
.then((r) => setHits(r.paths))
.catch(() => setHits([]));
}, 150);
return () => window.clearTimeout(timer);
}, [mode, laneId, query]);
// The whole-file read is a different endpoint from the diff, so it is its
// own fetch — and only while the viewer is actually showing it.
useEffect(() => {
if (mode !== "file" || !whole || !openFile) return;
void api.lanes
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.then((r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]);
// A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one.
useEffect(() => {
setMode("overview");
setOpenFile(null);
setSha(null);
setRange("work");
setTreeChildren({});
setExpandedDirs(new Set());
setCommits(null);
setCollapsed(new Set());
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
// Restoring has to wait for the list to be TALL enough again: leaving a file
// re-fetches the mode it came from, so the first render back is an empty
// box that would swallow the scroll.
useLayoutEffect(() => {
const el = scroller.current;
const want = pendingScroll.current;
if (mode === "file" || want == null || !el) return;
if (el.scrollHeight - el.clientHeight < want) return;
el.scrollTop = want;
pendingScroll.current = null;
}, [mode, data]);
const show = (path: string) => {
// Opening a file FROM a file (the review list is gone by then) must not
// overwrite the list position that is still waiting to be restored.
if (mode !== "file") {
returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 };
}
setOpenFile(path);
setContext(CONTEXT_STEPS[0]);
setWhole(false);
setText(null);
setMode("file");
};
const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => {
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(laneId, action, { paths: [path], ...(confirm ? { confirm } : {}) });
setConfirmDiscard(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const commitStaged = async () => {
setBusy(true);
setError(null);
try {
// `only: "staged"` — the whole point of staging file by file is that the
// commit takes the index as it stands, with no `add -A` sweeping the
// rest of the tree in behind it.
await api.lanes.gitAction(laneId, "commit", { message: message.trim(), only: "staged" });
setMessage("");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
const Row = ({ f }: { f: LaneChangeFile }) => (
<li className="group flex items-baseline gap-1">
<button
type="button"
onClick={() => show(f.path)}
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span
className={`w-3 shrink-0 font-mono text-[11px] font-semibold ${statusClass(
f.status,
f.untracked
)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
title={f.path}
>
{f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" && (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => void act("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => void act("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={confirmDiscard === f.path ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy}
danger
onClick={() => {
// Two clicks, always: the first arms the row, the second is the
// only one that reaches the server (which itself refuses the
// call without `confirm: true`).
if (confirmDiscard === f.path) void act("discard", f.path, true);
else setConfirmDiscard(f.path);
}}
>
<Trash2
className={`h-3 w-3 ${confirmDiscard === f.path ? "text-status-danger" : ""}`}
/>
</IconButton>
</span>
)}
</li>
);
return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
{/* Two rows on purpose: WHERE you are (mode, and the refresh that always
applies) on top, HOW you are looking at it (range, layout, context,
whole file) underneath. One row wrapped mid-group as soon as the
panel was narrow, which is every panel beside a console. */}
<div className="mb-2 space-y-1.5">
<div className="flex items-center gap-1.5">
{mode === "file" ? (
<button
type="button"
onClick={() => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
}}
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{(["browse", "overview", "review", "history"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => setMode(m)}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m
? "bg-accent text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`changes.mode.${m}`)}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => void load()}
aria-label={t("changes.refresh")}
title={t("changes.refresh")}
className="ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{mode !== "history" && mode !== "browse" && (
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["work", "branch"] as const).map((r) => (
<button
key={r}
type="button"
aria-pressed={range === r}
onClick={() => {
setRange(r);
setSha(null);
}}
title={t(`changes.range.${r}Hint`)}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
range === r
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`changes.range.${r}`)}
</button>
))}
{range === "commit" && sha && (
<span
data-testid="lane-changes-commit-chip"
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
>
{sha.slice(0, 7)}
</span>
)}
</div>
{(mode === "file" || mode === "review") && (
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
{mode === "review" && (
<IconButton
label={allCollapsed ? t("changes.expandAll") : t("changes.collapseAll")}
disabled={files.length === 0}
onClick={() =>
setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path)))
}
>
{allCollapsed ? (
<ChevronsUpDown className="h-3.5 w-3.5" />
) : (
<ChevronsDownUp className="h-3.5 w-3.5" />
)}
</IconButton>
)}
<IconButton
label={t(`changes.layout.${layout === "unified" ? "split" : "unified"}`)}
onClick={() => setLayout(layout === "unified" ? "split" : "unified")}
>
{layout === "unified" ? (
<Columns2 className="h-3.5 w-3.5" />
) : (
<Rows3 className="h-3.5 w-3.5" />
)}
</IconButton>
{mode === "file" && (
<>
{/* Context walks BOTH ways: an expanded diff that cannot
be collapsed again leaves the reader re-opening the
file to get the tight view back. */}
<IconButton
label={t("changes.collapseContext")}
disabled={whole || context <= CONTEXT_STEPS[0]}
onClick={() =>
setContext(
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
CONTEXT_STEPS[0]
)
}
>
<FoldVertical className="h-3.5 w-3.5" />
</IconButton>
<IconButton
label={t("changes.expandContext")}
disabled={whole || context >= LAST_CONTEXT}
onClick={() => setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)}
>
<UnfoldVertical className="h-3.5 w-3.5" />
</IconButton>
</>
)}
</div>
)}
{mode === "file" && (
<button
type="button"
aria-pressed={whole}
onClick={() => setWhole(!whole)}
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
}`}
>
{t("changes.wholeFile")}
</button>
)}
</div>
)}
</div>
{error && (
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
{error}
</p>
)}
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
bar sits at the bottom of the visible box instead of at the bottom of
a 2000-line container nobody has scrolled to yet. Every other mode is
a list, and a list scrolls here. */}
<div
ref={scroller}
data-testid="lane-changes-body"
className={`flex min-h-0 flex-1 flex-col ${
mode === "file" ? "overflow-hidden" : "overflow-y-auto"
}`}
>
{mode === "history" ? (
<ul data-testid="lane-changes-history" className="space-y-0.5">
{(commits ?? []).map((c) => (
<li key={c.sha}>
<button
type="button"
onClick={() => {
setSha(c.sha);
setRange("commit");
setMode("review");
}}
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</button>
</li>
))}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
</ul>
) : mode === "browse" ? (
<div data-testid="lane-changes-browse">
<div className="mb-2 flex items-center gap-1.5">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("changes.quickOpen")}
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
{!hits && (
<IconButton
label={
expandedDirs.size > 0
? t("changes.collapseAllFolders")
: t("changes.expandAllFolders")
}
disabled={treeBusy}
onClick={() =>
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
}
>
{expandedDirs.size > 0 ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</IconButton>
)}
</div>
{hits ? (
<ul className="space-y-0.5">
{hits.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => {
show(p);
setWhole(true);
}}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
</button>
</li>
))}
{hits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
)}
</ul>
) : (
<ul className="space-y-0.5">
<TreeNodes
path=""
depth={0}
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={(p) => {
show(p);
setWhole(true);
}}
/>
</ul>
)}
</div>
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
) : files.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
) : mode === "overview" ? (
<>
<ul data-testid="lane-changes-overview" className="space-y-0.5">
{files.map((f) => (
<Row key={f.path} f={f} />
))}
</ul>
{stagedCount > 0 && (
<div
data-testid="lane-changes-commit"
className="mt-2 flex items-center gap-1.5 border-t border-border-light pt-2"
>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
}}
placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || !message.trim()}
onClick={() => void commitStaged()}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.commitStaged", { count: stagedCount })}
</button>
</div>
)}
</>
) : mode === "file" ? (
current || (whole && text) ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1 flex items-baseline gap-1.5">
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
{openFile}
</span>
{current && (
<Delta added={current.added} deleted={current.deleted} binary={current.binary} />
)}
</div>
{whole ? (
text ? (
<LaneFileView
path={text.path}
lines={text.lines}
truncated={text.truncated}
fill
/>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
)
) : current ? (
<LaneDiffView file={current} layout={layout} fill />
) : null}
</div>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.fileGone")}</p>
)
) : (
<div data-testid="lane-changes-review" className="space-y-3">
{files.map((f) => {
const shut = collapsed.has(f.path);
return (
<div key={f.path} className="rounded border border-border-light">
{/* The whole header folds the file; only the PATH opens it.
A header that opened the file on any click made folding a
target-practice exercise against one chevron. */}
<div
role="button"
tabIndex={0}
onClick={() => toggleCollapsed(f.path)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCollapsed(f.path);
}
}}
aria-expanded={!shut}
aria-label={
shut
? t("changes.expandFile", { path: f.path })
: t("changes.collapseFile", { path: f.path })
}
className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
shut ? "" : "border-b border-border-light"
}`}
>
<ChevronRight
className={`h-3 w-3 shrink-0 self-center text-fg-muted transition-transform ${
shut ? "" : "rotate-90"
}`}
/>
<span
className={`shrink-0 font-mono text-[11px] font-semibold ${statusClass(f.status, f.untracked)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
show(f.path);
}}
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-fg-primary hover:underline"
title={t("changes.openFile", { path: f.path })}
>
{f.path}
</button>
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</div>
{!shut && <LaneDiffView file={f} layout={layout} />}
</div>
);
})}
</div>
)}
{data?.available && data.truncated && mode !== "history" && mode !== "browse" && (
<p className="mt-2 text-[10px] text-status-warning">{t("changes.truncated")}</p>
)}
</div>
</div>
);
}
export default LaneChangesPanel;
@@ -0,0 +1,326 @@
/**
* @file The diff renderer behind the Changes panel (issue #35): one file's
* hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted,
* with the changed span inside a modified line picked out.
*
* Three deliberate choices:
* - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation
* view already ships. No new dependency for a panel that renders a few
* hundred lines.
* - 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
export type DiffLayout = "unified" | "split";
/**
* How tall a diff box may be. `fill` (the single-file view) takes the height
* its parent gives it; otherwise — the review list, which stacks many files —
* each file is capped so its own horizontal scrollbar stays on screen.
*/
function boxHeight(fill: boolean) {
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
}
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) {
const ext = filePath.split(".").pop() || "";
return canonicalLang(ext === filePath ? "plain" : ext);
}
/** Tokenized line text. `emph` marks the span that actually changed. */
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) {
const [from, to] = emph;
return (
<>
<Code text={text.slice(0, from)} lang={lang} />
<span className="rounded-sm bg-fg-primary/15">
<Code text={text.slice(from, to)} lang={lang} />
</span>
<Code text={text.slice(to)} lang={lang} />
</>
);
}
return (
<>
{highlight(text, lang).map((tok, i) => (
<span key={i} className={tokenClass(tok.type)}>
{tok.text}
</span>
))}
</>
);
}
/**
* The changed span of a del/add pair, as `[start, end]` offsets into each
* side, or null when the lines share nothing worth pointing at.
*/
function emphasis(
del: string,
add: string
): { del: [number, number]; add: [number, number] } | null {
if (del === add) return null;
let start = 0;
const max = Math.min(del.length, add.length);
while (start < max && del[start] === add[start]) start += 1;
let end = 0;
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
// Nothing in common at either edge: the whole line is the change, and
// highlighting all of it adds noise on top of the row tint.
if (start === 0 && end === 0) return null;
return {
del: [start, del.length - end],
add: [start, add.length - end],
};
}
/** Pair each run of removed lines with the run of added lines following it. */
function pairs(
lines: LaneDiffLine[]
): Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> {
const out: Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> = [];
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
out.push({ left: line, right: line });
i += 1;
continue;
}
const dels: LaneDiffLine[] = [];
const adds: LaneDiffLine[] = [];
while (i < lines.length && lines[i]!.type === "del") dels.push(lines[i++]!);
while (i < lines.length && lines[i]!.type === "add") adds.push(lines[i++]!);
for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) {
out.push({ left: dels[k] ?? null, right: adds[k] ?? null });
}
}
return out;
}
function Gutter({ n }: { n: number | null }) {
return (
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
);
}
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. */
function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
}
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
* separate from context at a glance on every theme (the /10 fill alone did not). */
function rowTint(type: LaneDiffLine["type"] | null) {
if (type === "add")
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
return "";
}
function markerClass(type: LaneDiffLine["type"]) {
if (type === "add") return "font-bold text-status-success";
if (type === "del") return "font-bold text-status-danger";
return "text-fg-muted";
}
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
const at = hunk.header.split("@@")[1];
return (
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</div>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const emph = new Map<number, [number, number]>();
for (const { left, right } of pairs(hunk.lines)) {
if (!left || !right || left.type !== "del" || right.type !== "add") continue;
const e = emphasis(left.text, right.text);
if (!e) continue;
emph.set(hunk.lines.indexOf(left), e.del);
emph.set(hunk.lines.indexOf(right), e.add);
}
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<Row key={i} tint={rowTint(line.type)}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph.get(i)} />
</span>
</Row>
))}
</div>
);
}
/** One side of the split view: a hunk header, then that side's rows. */
function SplitColumn({
hunks,
lang,
side,
}: {
hunks: LaneDiffHunk[];
lang: string;
side: "left" | "right";
}) {
return (
<>
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} />
{pairs(hunk.lines).map(({ left, right }, i) => {
const e =
left && right && left.type === "del" && right.type === "add"
? emphasis(left.text, right.text)
: null;
const line = side === "left" ? left : right;
return (
<Row key={i} tint={rowTint(line?.type ?? null)}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code text={line.text} lang={lang} emph={side === "left" ? e?.del : e?.add} />
) : null}
</span>
</Row>
);
})}
</div>
))}
</>
);
}
/** Both sides, each scrolling horizontally on its own but kept in step. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
// Mirroring fires the other pane's own scroll handler, which mirrors back —
// the equality check is what ends that, so no re-entry flag is needed.
const sync = useCallback((from: "left" | "right") => {
const src = from === "left" ? left.current : right.current;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst || dst.scrollLeft === src.scrollLeft) return;
dst.scrollLeft = src.scrollLeft;
}, []);
return (
<div className={`flex ${boxHeight(fill)}`}>
<div
ref={left}
data-testid="lane-diff-split-left"
onScroll={() => sync("left")}
className="w-1/2 min-w-0 overflow-auto"
>
<SplitColumn hunks={hunks} lang={lang} side="left" />
</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"
>
<SplitColumn hunks={hunks} lang={lang} side="right" />
</div>
</div>
);
}
/** One file's diff, in the chosen layout. */
export function LaneDiffView({
file,
layout,
fill = false,
}: {
file: LaneChangeFile;
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
if (!file.hunks || file.hunks.length === 0)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
return (
<div
data-testid="lane-diff-view"
data-layout={layout}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
))
)}
{file.truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
);
}
/** A whole file's text (no diff), for the viewer's "read the file" mode. */
export function LaneFileView({
path,
lines,
truncated,
fill = false,
}: {
path: string;
lines: string[];
truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */
fill?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
return (
<div
data-testid="lane-file-view"
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
>
{lines.map((text, i) => (
<Row key={i} tint="">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
))}
{truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
);
}
export default LaneDiffView;
+352
View File
@@ -0,0 +1,352 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, commit (everything, or only what the Changes panel has
* staged), switch branch, and create/delete a branch. Per-file staging and
* discard live in the Changes panel, beside the file they act on.
*
* It renders beside the read-only git line in LaneCard rather than inside the
* ⋯ menu: that menu holds the rare and dangerous (`reset worktree`), and these
* are the opposite — the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
Check,
GitBranch,
Plus,
RefreshCw,
Trash2,
Upload,
X,
} from "lucide-react";
import { api } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action = "fetch" | "pull" | "push" | "commit" | "switch" | "branch-create" | "branch-delete";
function Btn({
label,
icon,
busy,
disabled,
title,
onClick,
}: {
label: string;
icon?: React.ReactNode;
busy?: boolean;
disabled?: boolean;
title?: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || busy}
title={title}
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{icon}
{label}
</button>
);
}
export function LaneGitBar({
laneId,
git,
onChanged,
onNewLane,
}: {
laneId: number;
git: LaneGitFacts;
/** Called after any action that could have moved the working copy. */
onChanged: () => void;
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
* a branch this bar just created. Omitted where no such flow is reachable. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState<Action | null>(null);
const [error, setError] = useState<string | null>(null);
const [committing, setCommitting] = useState(false);
const [message, setMessage] = useState("");
const [switching, setSwitching] = useState(false);
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
const run = async (
action: Action,
body?: { message?: string; only?: "staged"; branch?: string }
) => {
setBusy(action);
setError(null);
try {
await api.lanes.gitAction(laneId, action, body || {});
setCommitting(false);
setMessage("");
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "switch") setSwitching(false);
else if (action.startsWith("branch-")) {
setNewBranch("");
setPicked("");
setBranches(null);
await refreshBranches();
}
onChanged();
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
setBranches(r.branches);
setWorktrees(r.worktrees || []);
} catch {
setBranches([]);
}
};
const openSwitch = async () => {
setSwitching((v) => !v);
setError(null);
if (branches) return;
await refreshBranches();
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
return (
<div data-testid="lane-git-bar" className="mb-2 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{git.upstream ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
className="flex items-center gap-1 font-mono text-[11px] text-fg-muted"
>
{(git.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning">
<ArrowUp className="h-3 w-3" />
{git.ahead}
</span>
)}
{(git.behind ?? 0) > 0 && (
<span className="flex items-center text-accent">
<ArrowDown className="h-3 w-3" />
{git.behind}
</span>
)}
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
</span>
) : (
git.hasRemote && (
<span className="font-mono text-[11px] text-fg-muted">{t("gitBar.noUpstream")}</span>
)
)}
<Btn
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3 w-3" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3 w-3" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
label={t("gitBar.push")}
icon={<Upload className="h-3 w-3" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pushHint")}
onClick={() => run("push")}
/>
<Btn
label={t("gitBar.commit")}
icon={<Check className="h-3 w-3" />}
disabled={dirty === 0 || !!busy}
title={dirty === 0 ? t("gitBar.commitNothing") : t("gitBar.commitHint")}
onClick={() => {
setCommitting((v) => !v);
setError(null);
}}
/>
<Btn
label={t("gitBar.switch")}
icon={<GitBranch className="h-3 w-3" />}
disabled={!!busy}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={openSwitch}
/>
</div>
{committing && (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim()) void run("commit", { message });
if (e.key === "Escape") setCommitting(false);
}}
placeholder={t("gitBar.commitPlaceholder")}
aria-label={t("gitBar.commitPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.commitAll", { count: dirty })}
busy={busy === "commit"}
disabled={!message.trim()}
onClick={() => void run("commit", { message })}
/>
{/* The Changes panel's per-file staging only means something if a
commit can take the index as it stands. The server answers
ENOCHANGES when nothing is staged, and that text lands below. */}
<Btn
label={t("gitBar.commitStaged")}
title={t("gitBar.commitStagedHint")}
busy={busy === "commit"}
disabled={!message.trim()}
onClick={() => void run("commit", { message, only: "staged" })}
/>
<button
type="button"
aria-label={t("gitBar.cancel")}
onClick={() => setCommitting(false)}
className="text-fg-muted hover:text-fg-primary"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
onChange={(e) => setPicked(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
const held = heldBy.get(b);
return (
<option key={b} value={b}>
{b}
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
</option>
);
})}
</select>
<Btn
label={t("gitBar.switchApply")}
busy={busy === "switch"}
// A branch git already has checked out somewhere else cannot be
// switched to — git allows one worktree per branch.
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
/>
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
title={t("gitBar.newLaneHint")}
disabled={!picked}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
/>
)}
<Btn
label={t("gitBar.branchDelete")}
icon={<Trash2 className="h-3 w-3" />}
busy={busy === "branch-delete"}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
title={t("gitBar.branchDeleteHint")}
onClick={() => void run("branch-delete", { branch: picked })}
/>
{dirty > 0 && (
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
)}
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<input
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
}}
placeholder={t("gitBar.branchNewPlaceholder")}
aria-label={t("gitBar.branchNewPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.branchCreate")}
icon={<Plus className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// Creating does not switch: the point is usually a sibling lane,
// and moving this pane's HEAD under a working agent is not.
title={t("gitBar.branchCreateHint")}
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
/>
</div>
)}
{error && (
<pre
role="alert"
data-testid="lane-git-bar-error"
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
>
{error}
</pre>
)}
</div>
);
}
export default LaneGitBar;
@@ -109,18 +109,6 @@ async function fillWorktreeForm(
beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.pipelines)
.mockReset()
.mockResolvedValue({
pipelines: [
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
{
id: "ship-feature",
name: "Ship feature (lane pipeline)",
nodes: new Array(16).fill({ id: "n" }),
},
],
});
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
@@ -134,9 +122,13 @@ beforeEach(() => {
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.lanes.routerApply)
.mockReset()
.mockResolvedValue({ tool: "claude", file: null, profile: "native" });
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
tool: "claude",
file: null,
restart_required: false,
run_id: null,
profile: "native",
});
// Only the built-in profile by default, so the router radio stays on Native
// and no test has to opt out of it.
vi.mocked(api.aiRouters.list)
@@ -177,8 +169,101 @@ function savedRouter(over: Record<string, unknown> = {}) {
};
}
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
const withWorktrees = {
branches: ["main", "feat/other", "feat/held"],
current: "main",
worktrees: [
{ path: "/Users/tester/projects/repo", branch: "main" },
{ path: "/lanes/repo__held", branch: "feat/held" },
],
};
it("lists the repo's existing worktrees once the path resolves", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
const panel = await screen.findByTestId("add-lane-worktrees");
expect(panel).toHaveTextContent("/lanes/repo__held");
expect(panel).toHaveTextContent("feat/held");
});
it("checks an existing branch out, sending no base", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Reuse a branch");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const select = await screen.findByLabelText("Branch to check out");
await user.selectOptions(select, "feat/other");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: "feat/other", base: undefined })
)
);
});
it("greys out a branch another worktree already holds", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const held = await screen.findByRole("option", { name: /feat\/held/ });
expect(held).toBeDisabled();
expect(held).toHaveTextContent("/lanes/repo__held");
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
});
it("lets the server name a new branch when the field is left empty", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Auto named");
// The placeholder previews the name the server will pick.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"feat/auto-named"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" })
)
);
});
});
describe("AddLaneModal — worktree mode (default)", () => {
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
// The branch NAME is no longer part of this list: an empty one means "let
// the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
@@ -187,6 +272,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main", "feat/other"],
current: "main",
worktrees: [],
});
renderModal();
const user = userEvent.setup();
@@ -219,7 +305,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn();
const onClose = vi.fn();
@@ -235,20 +325,24 @@ describe("AddLaneModal — worktree mode (default)", () => {
title: "New feature",
base: "main",
branch: "feat/new-feature",
pipeline: "default",
tool: "claude",
});
});
expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" })
);
// The modal stays open showing the setup summary until dismissed - it
// does not close itself just because the lane was added.
expect(onClose).not.toHaveBeenCalled();
// Setup came back clean, so there is nothing to report and the dialog is
// done. Leaving it up read as "still working" — its confirm button is dead
// once the lane exists — which is how it looked stuck.
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
const onClose = vi.fn();
renderModal({ onClose });
@@ -267,7 +361,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
});
@@ -286,7 +384,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("still calls onAdded even when every setup call fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
});
@@ -305,11 +407,16 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("shows the setup summary and lets the user dismiss it manually", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
@@ -317,17 +424,22 @@ describe("AddLaneModal — worktree mode (default)", () => {
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
// A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
if (!dismissButton) throw new Error("Cancel button not found");
await user.click(dismissButton);
// And then the primary button must close, not sit there disabled: the lane
// already exists, so "Add lane" has nothing left to do.
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
});
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
});
@@ -390,8 +502,8 @@ describe("AddLaneModal — repo mode (adopt)", () => {
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/Users/tester/projects/repo",
title: "main repo",
pipeline: "default",
tool: "claude",
exact: true,
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
@@ -400,6 +512,28 @@ describe("AddLaneModal — repo mode (adopt)", () => {
// close right away like the old adopt flow did.
expect(onClose).toHaveBeenCalled();
});
it("says the folder already has a lane instead of closing silently (#42)", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 3, kind: "adopted", status: "idle", title: "SG - Services" }),
created: false,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument();
// The existing lane is selected so the user sees it, but nothing claims a new one.
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — folder browse", () => {
@@ -443,37 +577,12 @@ describe("AddLaneModal — folder browse", () => {
});
describe("AddLaneModal — create/update readme", () => {
it("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
vi.mocked(api.run.start)
.mockReset()
.mockResolvedValue({} as never);
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
it("no longer offers it here — it moved to the lane's Tools menu", async () => {
// Creating a lane and writing its docs are separate decisions, and the
// second one is worth re-running later; see LaneToolsMenu.
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByLabelText("Create/update readme file"));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.run.start).toHaveBeenCalledWith({
laneId: 9,
cwd: "/Users/tester/projects/repo",
initialPrompt: "/sg-all:sg-md-writing",
})
);
});
it("is hidden for a codex lane, which has no slash commands", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Codex" }));
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
});
});
@@ -609,50 +718,6 @@ describe("AddLaneModal — a router profile that disappeared", () => {
});
});
describe("AddLaneModal — pipeline template", () => {
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
// Creation is the ONLY point the UI can set a template, so a lane born on
// `default` renders 8 nodes for a 16-node workflow with no way back from
// any screen.
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() =>
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
);
expect(
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
).toBeInTheDocument();
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.selectOptions(select, "ship-feature");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(
expect.objectContaining({ pipeline: "ship-feature" })
)
);
});
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
renderModal();
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() => expect(select).toHaveValue("default"));
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
});
});
describe("AddLaneModal — tool selection", () => {
it("sends the picked tool when adopting a repo", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
@@ -697,3 +762,41 @@ describe("AddLaneModal — tool selection", () => {
);
});
});
describe("AddLaneModal — working straight on main (no worktree)", () => {
beforeEach(() => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["development", "main"],
current: "development",
worktrees: [],
});
});
it("jumps to Repo mode from the branch choice", async () => {
const user = userEvent.setup();
renderModal();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByTestId("add-lane-branch-section");
await user.click(screen.getByRole("button", { name: "Current branch" }));
// Repo mode adopts the directory as it stands — no branch section left to
// answer, because there is no fork to configure.
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
});
it("prefills the repo and branch it was opened with", async () => {
renderModal({
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
});
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
});
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
// "adopted" is shown as context, not as a refusal.
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -191,7 +191,7 @@ describe("DestructiveLaneModal", () => {
await waitFor(() => expect(confirmButton).toBeDisabled());
});
it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => {
it("ENABLES remove when the worktree directory is missing — the row goes, git's records stay", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
@@ -212,7 +212,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -238,7 +238,7 @@ describe("DestructiveLaneModal", () => {
await waitFor(() => expect(confirmButton).toBeDisabled());
});
it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => {
it("ENABLES remove when the worktree directory is unreadable — nothing on disk is touched either way", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
fireEvent.click(confirmButton);
@@ -268,23 +268,34 @@ describe("DestructiveLaneModal", () => {
});
});
it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => {
it("never offers Force for a managed removal with unpushed commits — it deletes the row, not the branch", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] })
worktreePreflight({
action: "remove",
head: "abc0000",
dirty: 0,
untracked: 0,
unpushed: 3,
blocked: ["unpushed-commits"],
})
);
const onConfirm = vi.fn();
render(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
open
onClose={vi.fn()}
onConfirm={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
await waitFor(() => expect(confirmButton).toBeDisabled());
fireEvent.click(screen.getByRole("checkbox"));
expect(confirmButton).not.toBeDisabled();
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 3 },
});
});
it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => {
@@ -353,10 +364,10 @@ describe("DestructiveLaneModal", () => {
});
});
it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => {
it("confirming an unpushed-commits RESET with Force ticked sends force:true plus the same expect block", async () => {
preflightMock.mockResolvedValue(
worktreePreflight({
action: "remove",
action: "reset",
head: "abc0000",
dirty: 0,
untracked: 0,
@@ -368,13 +379,13 @@ describe("DestructiveLaneModal", () => {
render(
<DestructiveLaneModal
lane={makeLane()}
action="remove"
action="reset"
open
onClose={vi.fn()}
onConfirm={onConfirm}
/>
);
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
fireEvent.click(screen.getByRole("checkbox"));
await waitFor(() => expect(confirmButton).not.toBeDisabled());
fireEvent.click(confirmButton);
@@ -9,7 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
@@ -34,6 +34,10 @@ vi.mock("../../../lib/api", () => ({
aiRouters: {
list: vi.fn(),
},
run: {
kill: vi.fn(),
start: vi.fn(),
},
},
}));
@@ -212,6 +216,12 @@ describe("LaneCard git block", () => {
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
{ path: "scratch.txt", status: "??", added: null, deleted: null },
],
// Tracking facts ride along on the same endpoint; a branch that has never
// been pushed reports nulls rather than zeros.
hasRemote: true,
upstream: null,
ahead: null,
behind: null,
};
it("renders the live branch, head, subject and uncommitted counts", async () => {
@@ -458,6 +468,29 @@ describe("LaneToolsMenu", () => {
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
});
it("clicking Create/update readme opens the lane's terminal on the docs skill", async () => {
// Moved here from the Add-lane modal: a skill only runs inside a CLI
// session, and writing the docs is worth re-running long after creation.
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneToolsMenu lane={makeLane({ cwd: "/work/demo" })} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-write-docs"));
await waitFor(() =>
expect(api.run.start).toHaveBeenCalledWith({
laneId: 1,
cwd: "/work/demo",
initialPrompt: "/sg-all:sg-md-writing",
})
);
});
it("hides Create/update readme on a codex lane, which has no slash commands", async () => {
render(<LaneToolsMenu lane={makeLane({ tool: "codex" })} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument();
expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument();
});
it("renders nothing when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
@@ -503,6 +536,8 @@ describe("LaneRouterRow — the Model picker", () => {
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
@@ -513,6 +548,109 @@ describe("LaneRouterRow — the Model picker", () => {
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("offers a restart when a live pane still holds the old router's models", async () => {
// Claude Code re-applies `env` to a running session but keeps the model it
// resolved at startup, so the switch is only half-applied until the pane is
// restarted — the server says so and the row must act on it.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: true,
run_id: "agenthub-lane-1",
});
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
const restart = await screen.findByRole("button", { name: "Restart" });
await userEvent.click(restart);
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
// Started by lane id alone: replaying the old run's --model would name a
// model of the router the user just left.
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
});
it("leaves the restart button out when no pane is running", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
// lane-detail header), so its selected value is component state that
// outlives a lane switch unless the sync effect puts the new lane's own
// profile back.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/b/.claude/settings.local.json",
profile: "9router",
restart_required: false,
run_id: null,
});
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneA} />);
await screen.findByDisplayValue("Native (Claude / Codex)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
});
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
// The row is one instance shared by every lane, so an apply still in flight
// when the user moves on would paint its rollback and its error on whoever
// is on screen by then — lane B's failure changing lane A's picker.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let reject: (e: Error) => void = () => {};
vi.mocked(api.lanes.routerApply).mockImplementation(
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
);
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
await act(async () => {
reject(new Error("EBADJSON"));
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByTitle("EBADJSON")).toBeNull();
// And the picker must not be left locked by a request for another lane.
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
it("puts the selection back when the apply fails", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
@@ -0,0 +1,540 @@
/**
* @file LaneChangesPanel.test.tsx
* @description Pins the Changes panel's five modes and its three ranges: the
* overview lists files and nothing else, clicking one opens that file's diff
* (and asks the server for THAT path), Review asks for every file's hunks at
* once, the range toggle changes which comparison is requested, History opens
* one commit, and Files quick-opens a whole file. Also pins the per-file git
* writes — including discard needing two clicks before the server hears
* `confirm` — and the degrade for a cwd git cannot read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
changes: vi.fn(),
gitAction: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
},
},
}));
const HUNK = {
header: "@@ -1,2 +1,3 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
{ type: "del" as const, oldLine: 2, newLine: null, text: "old line" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "new line" },
],
};
function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}): LaneChanges {
return {
available: true,
range: "work",
base: "development",
baseRef: "origin/development",
sha: null,
context: 3,
truncated: false,
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 12,
deleted: 3,
binary: false,
},
{ path: "notes.txt", status: "??", untracked: true, added: 4, deleted: 0, binary: false },
{
path: "docs/API.md",
status: "M ",
untracked: false,
staged: true,
added: 2,
deleted: 1,
binary: false,
},
],
...over,
};
}
beforeEach(() => {
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
vi.mocked(api.lanes.gitAction).mockReset();
vi.mocked(api.lanes.gitAction).mockResolvedValue({ action: "stage", ok: true, output: "" });
vi.mocked(api.lanes.gitLog).mockResolvedValue({
available: true,
commits: [
{
sha: "a".repeat(40),
short: "aaaaaaa",
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
refs: ["HEAD -> feat/thing"],
},
],
});
vi.mocked(api.lanes.gitLs).mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "src", path: "src", dir: true }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
});
});
describe("LaneChangesPanel", () => {
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.queryByText(/new line/)).toBeNull();
});
it("opens one file's diff when its row is clicked, asking for that path only", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 12,
deleted: 3,
binary: false,
hunks: [HUNK],
},
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
// The highlighter splits a line into one span per token, so assert on the
// rendered text of the whole view rather than on a single node.
const view = await screen.findByTestId("lane-diff-view");
expect(view).toHaveTextContent("new line");
// Both gutters are rendered, so a reviewer can cite a line number.
expect(view).toHaveTextContent("keep");
});
it("asks for every file's hunks in review mode", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", full: true })
);
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
});
it("switches the comparison when the range toggle is used", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "vs base" }));
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
});
it("says so, quietly, when the lane's directory is not a git repo", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<LaneChangesPanel laneId={7} />);
expect(
await screen.findByText("This lane's directory is not a readable git repository.")
).toBeInTheDocument();
});
it("stages one file and asks the server for that path alone", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// Both rows offer it (one modified, one untracked); the first is the
// modified file the assertion below names.
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "Click again to discard — this cannot be undone" })
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js"],
confirm: true,
});
});
it("opens a commit from history as a commit-range review", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
await user.click(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "commit",
sha: "a".repeat(40),
full: true,
})
);
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
});
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
path === "src"
? {
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
truncated: false,
}
: {
available: true,
path: "",
entries: [{ name: "src", path: "src", dir: true }],
truncated: false,
}
);
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.click(await screen.findByText("src"));
expect(await screen.findByText("main.ts")).toBeInTheDocument();
// The root's "src" row is still there — expanding did not replace the list.
expect(screen.getByText("src")).toBeInTheDocument();
});
it("expands every folder at once, fetching whatever level wasn't cached yet", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => {
if (path === "src")
return {
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
truncated: false,
};
return {
available: true,
path: "",
entries: [{ name: "src", path: "src", dir: true }],
truncated: false,
};
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
expect(await screen.findByText("main.ts")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Collapse all folders" }));
expect(screen.queryByText("main.ts")).not.toBeInTheDocument();
expect(screen.getByText("src")).toBeInTheDocument();
});
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await waitFor(() => expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "work"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
});
it("switches the diff to side-by-side", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 12,
deleted: 3,
binary: false,
hunks: [HUNK],
},
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
expect(screen.getByTestId("lane-diff-view")).toHaveAttribute("data-layout", "split");
});
it("expands and then collapses the context again", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an
// expanded diff that cannot be collapsed sends the reader back out of the
// file to get the tight view.
expect(screen.getByRole("button", { name: "Show fewer surrounding lines" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 12,
})
);
await user.click(screen.getByRole("button", { name: "Show fewer surrounding lines" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("asks for more context when the diff is expanded", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 12,
})
);
});
it("commits the index from the overview, never the whole tree", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// The box exists only because a file is staged — with a clean index there
// is nothing this button could mean.
const box = await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
expect(box).toBeInTheDocument();
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: the staged half",
only: "staged",
});
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("puts Files first in the mode row", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const modes = ["Files", "Overview", "Review", "History"].map((name) =>
screen.getByRole("button", { name })
);
// Source order is the reading order here, so comparing positions pins it.
for (let i = 1; i < modes.length; i += 1) {
expect(
modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
}
});
it("folds one file shut in review mode, and all of them at once", async () => {
const user = userEvent.setup();
const withHunks = changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 1,
deleted: 1,
binary: false,
hunks: [HUNK],
},
{
path: "notes.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
hunks: [HUNK],
},
],
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(withHunks);
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(1);
// The header stays, so a folded file is still a row you can reopen.
expect(
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Collapse every file" }));
expect(screen.queryAllByTestId("lane-diff-view")).toHaveLength(0);
await user.click(screen.getByRole("button", { name: "Expand every file" }));
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
});
it("opens a file only from its path, and folds it from anywhere else", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
// Anywhere on the header but the path: fold, do not navigate.
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
expect(screen.getByTestId("lane-changes-review")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("goes back to the list it came from, at the offset it was left at", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
// jsdom lays nothing out, so the scrollable box is described by hand —
// the restore deliberately waits until the list is tall enough again.
const body = screen.getByTestId("lane-changes-body");
Object.defineProperty(body, "scrollHeight", { value: 2000, configurable: true });
Object.defineProperty(body, "clientHeight", { value: 400, configurable: true });
body.scrollTop = 640;
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
await screen.findByRole("button", { name: /All files/ });
await user.click(screen.getByRole("button", { name: /All files/ }));
// Back to REVIEW (where the file was opened from), not to the overview.
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
});
});
@@ -0,0 +1,160 @@
/**
* @file LaneDiffView.test.tsx
* @description Pins the side-by-side renderer: two independently scrolling
* columns (a long line must be reachable, not painted over its neighbour)
* whose horizontal scroll is mirrored, so the two halves of a line stay
* opposite each other.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { LaneDiffView } from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 1,
deleted: 1,
binary: false,
hunks: [
{
header: "@@ -1,2 +1,2 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
],
},
],
};
describe("LaneDiffView — side by side", () => {
it("gives each column its own scroller", () => {
render(<LaneDiffView file={FILE} layout="split" />);
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
});
it("bounds its own height so the horizontal bar stays on screen", () => {
// A stacked review list caps each file; the single-file view takes the
// height its parent gives it. Either way the diff scrolls itself rather
// than growing a 2000-line container whose scrollbar is off the bottom.
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
it("mirrors one column's horizontal scroll onto the other", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
left.scrollLeft = 320;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(320);
right.scrollLeft = 0;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(0);
});
it("keeps the unified layout on one scroller", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
});
});
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
}
const ROWS = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
]);
/** The unified row whose text contains `needle`. */
function rowOf(container: HTMLElement, needle: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
r.textContent?.includes(needle)
);
if (!row) throw new Error(`no row for ${needle}`);
return row;
}
describe("LaneDiffView — row styling", () => {
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const add = rowOf(container, "x = 2");
const del = rowOf(container, "x = 1");
const ctx = rowOf(container, "keep()");
expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger");
expect(ctx.className).not.toMatch(/status-(success|danger)/);
});
it("colours the +/- marker bold in its status colour, context marker muted", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "x = 2"));
const minus = marker(rowOf(container, "x = 1"));
const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold");
expect(plus.className).toContain("text-status-success");
expect(minus.textContent).toBe("-");
expect(minus.className).toContain("text-status-danger");
expect(ctx.className).toContain("text-fg-muted");
expect(ctx.className).not.toContain("font-bold");
});
it("emphasises only the changed span of a paired del/add", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
(e) => e.textContent
);
expect(emph).toEqual(["1", "2"]);
});
it("does not emphasise a line that changed completely", () => {
const { container } = render(
<LaneDiffView
file={fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
])}
layout="unified"
/>
);
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
});
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
render(
<LaneDiffView
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
layout="split"
/>
);
const rows = (id: string) => [
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
];
const [right] = rows("lane-diff-split-right");
const [left] = rows("lane-diff-split-left");
expect(right!.className).toContain("bg-status-success/20");
expect(left!.className).not.toMatch(/status-(success|danger)/);
});
});
@@ -0,0 +1,175 @@
/**
* @file LaneGitBar.test.tsx
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press, that commit sends the typed
* message, that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
return {
available: true,
branch: "feat/thing",
head: "9b3e74a",
subject: "wip",
dirty: 2,
untracked: 1,
files: [],
hasRemote: true,
upstream: "origin/feat/thing",
ahead: 2,
behind: 1,
...over,
};
}
beforeEach(() => {
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
action: "fetch",
ok: true,
output: "",
});
vi.mocked(api.lanes.gitBranches)
.mockReset()
.mockResolvedValue({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [],
});
});
describe("LaneGitBar", () => {
it("shows both sides of the upstream comparison", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
const tracking = screen.getByTestId("lane-git-tracking");
expect(tracking).toHaveTextContent("2");
expect(tracking).toHaveTextContent("1");
});
it("disables the network actions when the repo has no remote", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ hasRemote: false, upstream: null })}
onChanged={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
// Local actions stay available — a repo with no remote still commits.
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
});
it("commits with the typed message and reports the change upward", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
);
expect(onChanged).toHaveBeenCalled();
});
it("offers no commit when nothing is uncommitted", () => {
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
// Picking is harmless; applying is what a dirty tree blocks.
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("switches branch on a clean tree", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await user.selectOptions(select, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
);
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
render(
<LaneGitBar
laneId={3}
git={facts({ dirty: 0, untracked: 0 })}
onChanged={vi.fn()}
onNewLane={onNewLane}
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "feat/sibling",
})
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "New lane here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
it("commits only the index when asked", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText(/Commit message/), "fix: staged only");
await user.click(screen.getByRole("button", { name: "Commit staged" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
message: "fix: staged only",
only: "staged",
})
);
});
it("prints git's own rejection text when an action fails", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
});
});
+79 -40
View File
@@ -15,6 +15,15 @@
* swaps the whole pane over to the new lane — its cwd, its tool, its history,
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* A lane's pane is one tmux session, so starting or resuming into a lane
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
* that and attach to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
* reachable and the user did not ask to kill it. `killRun` is the other half:
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
* DOES want that previous session gone so a fresh start/resume can proceed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -75,7 +84,7 @@ export function LaneConsolePane({
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("auto");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
@@ -129,7 +138,6 @@ export function LaneConsolePane({
useEffect(() => {
const { activeRuns: runs } = latest.current;
setPrompt("");
lastPrefill.current = "";
setResumeSession(null);
setError(null);
setBusy(null);
@@ -138,34 +146,10 @@ export function LaneConsolePane({
refreshList();
}, [laneId, refreshList]);
// Pre-fill the prompt with the slash command the lane's pipeline template
// names as its driver, so picking the 16-node pipeline starts the 16-step
// skill and the 8-node one the 8-step skill. Only overwrites a prompt the
// user hasn't touched (still exactly the previous prefill), so switching
// templates never eats a typed requirement. Must be declared AFTER the
// lane-switch effect: on a lane change that one clears both first.
const lastPrefill = useRef("");
// Claude gets a slash command it can execute (`pipeline_skill`), which itself
// declares stage at every step. Codex has no slash commands — the same
// string would read as literal prose — so it gets the equivalent priming a
// different way: a plain-text checklist of this lane's own pipeline node ids,
// read directly off `pipeline_nodes` so it always matches the template in
// use. Without SOME opening nudge a codex lane's declared stage depends
// entirely on AGENTS.md being read on its own, which is easy to miss.
const promptPrefill =
laneTool === "codex"
? currentLane?.pipeline_nodes?.length
? `Declare progress as you go: run \`agenthub stage <stage> --tool codex --evidence "..."\` immediately at each real transition through this pipeline — ${currentLane.pipeline_nodes.map((n) => n.id).join(" -> ")}. Then, your task:\n\n`
: ""
: currentLane?.pipeline_skill || "";
useEffect(() => {
const next = promptPrefill && laneTool === "claude" ? `${promptPrefill} ` : promptPrefill;
setPrompt((p) => (p === lastPrefill.current ? next : p));
lastPrefill.current = next;
// `laneId` is a dep too: switching between two lanes on the SAME template
// leaves `promptPrefill` unchanged, and the switch effect just cleared the
// prompt — without it the new lane would open with an empty box.
}, [promptPrefill, laneTool, laneId]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
// run starts, which is an extra prompt nobody asked for. The prompt is the
// user's to type; the pipeline is declared by `agenthub stage` instead.
// The switch effect above only sees whatever `activeRuns` the page already
// had loaded at that instant. Remounting this page (navigating away and
@@ -271,12 +255,16 @@ export function LaneConsolePane({
resumeLast: args.resumeLast,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
// A code-only check: the server's message is human prose ("lane
// already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
}
@@ -332,13 +320,34 @@ export function LaneConsolePane({
onLaneCreated(ensureResult.lane);
}
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
} catch (laneErr: unknown) {
// The lane's own pane is still busy with a PREVIOUS live session —
// that session is still active and reachable, so land on it
// instead of failing outright. Killing it (Active runs → Kill) is
// what actually frees the lane for this resume. Checked by `.code`
// — the server's `.message` is prose, never the literal string
// "ERUNLIVE" or "409".
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
onLaneIdChange(targetLaneId);
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
throw new Error("No run_id returned from lane start");
@@ -368,7 +377,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
@@ -385,6 +394,28 @@ export function LaneConsolePane({
setError(null);
}, []);
// Frees a lane blocked by a live run nobody wants anymore (see Active
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
const killRun = useCallback(
async (id: string) => {
setBusy("kill");
setError(null);
try {
await api.run.kill(id);
if (handle?.id === id) newRun();
refreshList();
} catch (err) {
setError(
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
);
} finally {
setBusy(null);
}
},
[handle, newRun, refreshList, t]
);
if (laneId === null && showLaneSelector) {
return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
@@ -463,6 +494,7 @@ export function LaneConsolePane({
externalSessions={externalSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onKill={killRun}
onRefresh={refreshList}
/>
</header>
@@ -516,6 +548,13 @@ export function LaneConsolePane({
<TerminalView
runId={handle!.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// go straight back to the setup form rather than leaving a dead
// terminal the user has to click "New run" out of.
onExit={() => {
newRun();
refreshList();
}}
/>
<button
onClick={newRun}
+51 -4
View File
@@ -10,18 +10,23 @@
* - `RunsModal` — the merged list itself: live in-memory handles from
* `activeRuns` deduped against persistent `runHistory`, newest first, with
* status / mode chip filters, a free-text search, and the per-row Attach /
* Resume / View actions.
* Resume / View / Kill actions.
*
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "Active runs" counts everything actually running. They carry no tmux session
* the dashboard can attach to, so their only action is Resume, which spawns a
* fresh tmux-backed `claude --resume <session>` in that cwd.
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
* from them for the same reason.
*
* Props only: no API call of its own. The page passes `activeRuns`,
* `runHistory` and `externalSessions` in and gets attach / resume / view /
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
* just calls `onRefresh`.
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
* runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
* discarding a file in the Changes panel) is what actually frees a lane whose
* pane is still busy with a previous session — `LaneConsolePane` needs it
* because starting or resuming into a lane already holding a live run answers
* 409 ERUNLIVE rather than overwriting a prompt in flight.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -39,6 +44,7 @@ import {
Search,
RotateCcw,
Eye,
Square,
} from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import type { Session } from "../../lib/types";
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
externalSessions = [],
onResumeFromHistory,
onViewFromHistory,
onKill,
onRefresh,
}: {
activeRuns: RunListResponse | null;
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
externalSessions?: Session[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
* spawned rows only — an external session has no tmux session here to
* kill. */
onKill: (id: string) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
setOpen(false);
onViewFromHistory(item);
}}
onKill={onKill}
runHistory={historyItems}
onClose={() => setOpen(false)}
onRefresh={onRefresh}
@@ -273,6 +285,7 @@ export function RunsModal({
onAttach,
onResume,
onView,
onKill,
runHistory,
onClose,
onRefresh,
@@ -282,6 +295,7 @@ export function RunsModal({
onAttach: (id: string) => void;
onResume: (item: DashboardRunHistoryItem) => void;
onView: (item: DashboardRunHistoryItem) => void;
onKill: (id: string) => void;
runHistory: DashboardRunHistoryItem[];
onClose: () => void;
onRefresh: () => void;
@@ -446,6 +460,7 @@ export function RunsModal({
const h = historyById.get(r.id);
if (h) onView(h);
}}
onKill={() => onKill(r.id)}
/>
);
})
@@ -512,14 +527,20 @@ function UnifiedRunRowView({
onAttach,
onResume,
onView,
onKill,
}: {
row: UnifiedRunRow;
isCurrent: boolean;
onAttach: () => void;
onResume: () => void;
onView: () => void;
onKill: () => void;
}) {
const { t } = useTranslation("run");
// Two clicks before the tmux session actually dies — same convention as
// discarding a file's changes in the Changes panel, for the one action
// here that cannot be undone.
const [confirmKill, setConfirmKill] = useState(false);
const startedDate = new Date(row.startedAt);
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
@@ -593,6 +614,32 @@ function UnifiedRunRowView({
{t("runs.viewLabel", "View")}
</button>
)}
{row.isLive && !row.external && (
<button
onClick={() => {
if (!confirmKill) {
setConfirmKill(true);
return;
}
setConfirmKill(false);
onKill();
}}
onBlur={() => setConfirmKill(false)}
title={
confirmKill
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
: t("runs.killLabel", "Kill")
}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
confirmKill
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
}`}
>
<Square className="w-3 h-3" />
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
</button>
)}
</span>
</div>
{row.promptPreview && (
+15 -3
View File
@@ -36,6 +36,7 @@ import {
ShieldAlert,
X,
FolderOpen,
GitBranch,
Home,
History as HistoryIcon,
Search,
@@ -76,7 +77,9 @@ interface PromptEditorProps {
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSubmit();
}
@@ -203,6 +206,7 @@ export function RunSetup(props: RunSetupProps) {
value={props.permissionMode}
onChange={props.onPermissionModeChange}
options={[
{ value: "auto", label: t("fields.permissionAuto") },
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
{ value: "default", label: t("fields.permissionDefault") },
{ value: "plan", label: t("fields.permissionPlan") },
@@ -360,7 +364,10 @@ function handleStart(props: RunSetupProps, session?: Session) {
effort: props.effort || undefined,
resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
initialPrompt: props.prompt || undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined,
});
}
@@ -445,7 +452,10 @@ export function CwdAutocomplete({
// Group suggestions by kind preserving fixed order — home first, matching
// the neutral default the page pre-fills (issue #202).
const groups = useMemo(() => {
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
// "repo" (source repos lanes were cut from) sits above the recents: on the
// Add-lane form it is the group the field is actually asking for, and on
// the Run form it is a short, deliberate list rather than history.
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"];
return order
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
.filter((g) => g.items.length > 0);
@@ -521,6 +531,8 @@ export function CwdAutocomplete({
<FolderOpen className="w-3 h-3" />
) : g.kind === "home" ? (
<Home className="w-3 h-3" />
) : g.kind === "repo" ? (
<GitBranch className="w-3 h-3" />
) : (
<HistoryIcon className="w-3 h-3" />
)}
+33 -3
View File
@@ -20,10 +20,19 @@ import "@xterm/xterm/css/xterm.css";
interface TerminalViewProps {
runId: string;
wsBaseUrl: string;
/** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit).
* There is nothing left to attach to, so the owner swaps this terminal back
* out for its run setup form instead of leaving a dead pane on screen. */
onExit?: () => void;
}
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
// every render, and as an effect dep it would tear the terminal down and
// re-attach on each one.
const onExitRef = useRef(onExit);
onExitRef.current = onExit;
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -35,8 +44,15 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too.
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
// cursor to column 0 on a line feed — and xterm's own docs say not to use
// it on a PTY, where termios already does that translation for the streams
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
// drawing at the column it was on; with this on, that draw landed at
// column 0 instead, stamping the first characters of the line into the
// left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled.
const term = new Terminal({
convertEol: true,
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
@@ -63,6 +79,10 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
}
fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor.
term.focus();
// When the pane's program has NOT enabled wheel reporting, xterm falls back
// to converting each wheel notch on an alt-screen buffer into a cursor-key
@@ -116,7 +136,16 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
let disposed = false;
const connect = () => {
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
// The attach itself carries this terminal's size. tmux sizes the window
// to the client that attached most recently, so spawning the attach at a
// default 80×24 and correcting it from the first `resize` message a beat
// later makes the pane's program (claude) redraw its whole UI at 80
// columns first — and everything painted at that width stays in the
// scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each 1.5 s reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
@@ -143,6 +172,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
if (msg.type === "exit") {
ended = true;
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
onExitRef.current?.();
}
return;
} catch {
@@ -6,13 +6,18 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
vi.mock("../TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
// out of claude), which the real TerminalView reports through `onExit`.
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
<div data-testid="terminal-view" data-run-id={runId}>
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
</div>
),
}));
@@ -130,6 +135,35 @@ describe("LaneConsolePane", () => {
);
});
it("returns to the setup form when the pane exits, without a New run click", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-1",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await screen.findByTestId("terminal-view");
fireEvent.click(screen.getByTestId("fake-pane-exit"));
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument();
});
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
const run = (id: string, laneId: number) => ({
@@ -195,40 +229,11 @@ describe("LaneConsolePane", () => {
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
});
it("pre-fills the prompt with the pipeline's driving skill, and keeps a typed prompt when the template changes", async () => {
const lane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
const props = { ...baseProps(), lanes: [lane] };
const { rerender } = render(<LaneConsolePane {...props} />);
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature "));
// Untouched prefill follows the template switch.
const laneShip = { ...lane, pipeline_skill: "/agenthub:ship-feature-lane" };
rerender(<LaneConsolePane {...props} lanes={[laneShip]} />);
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane "));
// A typed requirement is never eaten by a later template switch.
fireEvent.change(prompt, { target: { value: "/agenthub:ship-feature-lane add a button" } });
rerender(<LaneConsolePane {...props} lanes={[lane]} />);
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane add a button"));
});
it("never pre-fills a claude slash command into a codex pane", async () => {
// pipeline_skill is a Claude Code slash command. Codex would take it as
// literal prose and run nothing, which is how a codex lane sits for half an
// hour with an untouched pipeline map. No pipeline_nodes here either, so
// there's nothing to build a stage checklist from — the box stays empty.
const lane = { ...LANE, tool: "codex" as const, pipeline_skill: "/agenthub:ship-feature-lane" };
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
await waitFor(() => expect(prompt.value).toBe(""));
});
it("pre-fills a codex pane with a stage-declaration checklist built from its own pipeline nodes", async () => {
// Codex has no slash commands, so it can't run pipeline_skill as-is — it
// gets the same priming Claude gets (an opening nudge to declare stage)
// as plain text instead, naming this lane's actual node ids.
const lane = {
it("never pre-fills the prompt from the lane's pipeline, for either tool", async () => {
// Picking a pipeline used to write its driving slash command (claude) or a
// stage checklist (codex) into the box — an extra prompt sent on start.
const claudeLane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
const codexLane = {
...LANE,
tool: "codex" as const,
pipeline_skill: "/agenthub:ship-feature-lane",
@@ -243,11 +248,81 @@ describe("LaneConsolePane", () => {
{ id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const },
],
};
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
const props = { ...baseProps(), lanes: [claudeLane] };
const { rerender } = render(<LaneConsolePane {...props} />);
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
await waitFor(() => expect(prompt.value).toContain("agenthub stage <stage> --tool codex"));
expect(prompt.value).toContain("implement -> tests");
expect(prompt.value).not.toContain("/agenthub:ship-feature-lane");
await waitFor(() => expect(prompt.value).toBe(""));
// A typed requirement still survives a template switch.
fireEvent.change(prompt, { target: { value: "add a button" } });
rerender(
<LaneConsolePane
{...props}
lanes={[{ ...claudeLane, pipeline_skill: "/agenthub:ship-feature-lane" }]}
/>
);
await waitFor(() => expect(prompt.value).toBe("add a button"));
rerender(<LaneConsolePane {...props} lanes={[codexLane]} />);
await waitFor(() => expect(prompt.value).toBe("add a button"));
});
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
const HISTORY_ITEM = {
id: "run-old",
session_id: "sess-old",
cwd: "/workspace/a",
model: null,
permission_mode: null,
effort: null,
resume_session_id: null,
prompt_preview: "earlier prompt",
status: "abandoned" as const,
exit_code: null,
started_at: new Date(1000).toISOString(),
ended_at: new Date(2000).toISOString(),
isLive: false,
tool: "claude" as const,
};
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
// Matches the real shape `request()` throws: a plain, human-readable
// `.message` plus a `.code` a caller actually branches on.
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
);
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
lanes: [{ ...LANE, run_id: "run-live" }],
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-live",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} />
</MemoryRouter>
);
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
const resumeButtons = await screen.findAllByRole("button", {
name: "Resume existing session",
});
fireEvent.click(resumeButtons[0]!);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
);
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
});
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
onAttach: vi.fn(),
onResumeFromHistory: vi.fn(),
onViewFromHistory: vi.fn(),
onKill: vi.fn(),
onRefresh: vi.fn(),
};
const utils = render(
@@ -127,6 +128,7 @@ function renderModal(
onAttach: vi.fn(),
onResume: vi.fn(),
onView: vi.fn(),
onKill: vi.fn(),
onClose: vi.fn(),
onRefresh: vi.fn(),
};
@@ -274,6 +276,19 @@ describe("RunsModal", () => {
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
});
it("needs two clicks to kill a live row, and never offers Kill for an external one", () => {
const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill"));
fireEvent.click(killButton);
expect(spies.onKill).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?")));
expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID);
unmount();
renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]);
expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull();
});
it("fires resume with the history item behind a finished row", () => {
const { spies } = renderModal([row(PAST_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
@@ -121,8 +121,10 @@ describe("RunSetup — selections report through callbacks", () => {
it("reports the model from the picker, including a custom id", () => {
const { spies } = renderSetup();
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 5");
expect(spies.onModelChange).toHaveBeenCalledWith("claude-sonnet-5");
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
// what keeps the list from going stale on every model release.
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
// the free-text box it reveals is what reports.
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
@@ -234,6 +236,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
);
});
it("sends no prompt when resuming, even with text in the box", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "leftover text" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
// The resumed session is the input; anything left in the box would be an
// extra turn the user never asked for.
expect(spies.onStart.mock.calls[0]?.[0]?.initialPrompt).toBeUndefined();
});
it("falls back to the typed cwd when no lane is selected", async () => {
const { api } = await import("../../../lib/api");
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
@@ -16,6 +16,7 @@ const openMock = vi.fn();
const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
const termState = {
@@ -54,6 +55,7 @@ vi.mock("@xterm/xterm", () => ({
loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
})),
}));
vi.mock("@xterm/addon-fit", () => ({
@@ -100,13 +102,27 @@ describe("TerminalView", () => {
keyHandlerMock.mockClear();
loadAddonMock.mockClear();
openMock.mockClear();
focusMock.mockClear();
});
afterEach(cleanup);
it("opens a WS connection to the run's ws-pty path", () => {
it("opens a WS connection to the run's ws-pty path, carrying its own size", () => {
// The size rides on the attach URL, not on a `resize` message sent after
// it: tmux resizes the window to the newest client, so attaching at the
// server's default 80x24 makes claude redraw its UI at 80 columns and
// leaves that width in the scrollback (issue #33).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
});
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
termState.cols = 0;
termState.rows = 0;
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
});
@@ -184,6 +200,24 @@ describe("TerminalView", () => {
expect(preventPlainTab).not.toHaveBeenCalled();
});
it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane.
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(focusMock).toHaveBeenCalled();
});
it("tells its owner when the pane exited, so the setup form can come back", () => {
// A double Ctrl+C out of `claude` ends the tmux session; the dead pane used
// to sit there until the user found the "New run" button.
const onExit = vi.fn();
render(
<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" onExit={onExit} />
);
MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' });
expect(onExit).toHaveBeenCalledTimes(1);
});
it("renders through the canvas addon, not xterm's DOM renderer", () => {
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
@@ -0,0 +1,128 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance: the Dark / Light / System mode switch and
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Palette, Sun } from "lucide-react";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
"intellij-light": "IntelliJ Light",
canvas: "Canvas",
};
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "dark", Icon: Moon },
{ mode: "light", Icon: Sun },
{ mode: "system", Icon: Monitor },
];
function Swatch({ mode, name }: { mode: Theme; name: string }) {
return (
<div
aria-hidden
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
data-mode={mode}
data-theme={name === "default" ? undefined : name}
>
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-accent" />
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
</div>
<div className="font-mono text-[10px] leading-tight">
<span className="text-syntax-keyword">const</span>{" "}
<span className="text-syntax-function">x</span>{" "}
<span className="text-syntax-operator">=</span>{" "}
<span className="text-syntax-string">"ok"</span>
</div>
<div className="flex gap-1">
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
</div>
</div>
</div>
);
}
export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
return (
<section id="appearance" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Palette className="w-4 h-4 text-fg-muted" />
{t("appearance.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
<div className="card p-5 space-y-5">
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{MODES.map(({ mode: m, Icon }) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
aria-pressed={mode === m}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
mode === m
? "bg-accent/20 text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</button>
))}
</div>
</div>
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</section>
);
}
@@ -0,0 +1,60 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AppearanceSettings } from "../AppearanceSettings";
const root = document.documentElement;
describe("AppearanceSettings", () => {
beforeEach(() => {
localStorage.clear();
root.classList.remove("dark");
root.removeAttribute("data-theme");
root.removeAttribute("data-mode");
});
it("renders every theme of both modes, defaults selected", () => {
render(<AppearanceSettings />);
const pressed = screen
.getAllByRole("button", { pressed: true })
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
});
it("picking a dark theme applies it at once; a light pick waits for light mode", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /Darcula/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /Canvas/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /^Light$/ }));
expect(root.classList.contains("dark")).toBe(false);
expect(root.dataset.theme).toBe("canvas");
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
});
it("the System mode button is pressed after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /System/ }));
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
it("each swatch scopes its own mode and theme", () => {
const { container } = render(<AppearanceSettings />);
expect(container.querySelector('.dark[data-theme="darcula"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
});
});
@@ -407,7 +407,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
{/* Hover tooltip (matches the app's other chart tooltips) */}
{tip && (
<div
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
className="fixed z-50 pointer-events-none rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-xs shadow-xl"
style={{
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
top: tip.y - 10,
@@ -853,7 +853,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
ref={tipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
className="fixed z-50 px-3 py-2 bg-surface-2 border border-border rounded-lg shadow-2xl pointer-events-none"
style={{
display: "none",
opacity: 0,
@@ -922,10 +922,11 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
row.style.cssText =
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
const lbl = document.createElement("span");
lbl.style.color = "#64748b";
lbl.style.color = "rgb(var(--fg-muted))";
lbl.textContent = label;
const val = document.createElement("span");
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
val.style.cssText =
"color:rgb(var(--fg-primary));font-weight:500;font-variant-numeric:tabular-nums";
val.textContent = value;
row.appendChild(lbl);
row.appendChild(val);
@@ -935,12 +936,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
const desc = document.createElement("p");
desc.style.cssText =
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
"font-size:11px;color:rgb(var(--fg-secondary));line-height:1.45;border-top:1px solid rgb(var(--border));padding-top:8px;margin:0";
desc.textContent = meta.description;
el.appendChild(desc);
const hint = document.createElement("p");
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
hint.style.cssText = "font-size:10px;color:rgb(var(--fg-muted));line-height:1.45;margin:6px 0 0";
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
el.appendChild(hint);
}
@@ -115,7 +115,7 @@ function Tooltip({ state }: { state: TooltipState }) {
return (
<div
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
className="fixed z-50 px-3 py-2 text-xs bg-surface-2 border border-border rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? state.x - 12 : state.x + 12,
top: state.y - 10,
@@ -144,7 +144,7 @@ function Tooltip({ state }: { state: TooltipState }) {
</span>
)}
</div>
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
<div className="mt-1 pt-1 border-t border-border">
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
</span>
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
return (
<div>
<div
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
>
{/* Depth connector line */}
@@ -252,7 +252,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{events.map((ev) => (
<div
key={ev.id}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
>
{/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
{recent.map((ev) => (
<div
key={ev.id}
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
>
{/* Event type badge */}
<span
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
key={s.id}
type="button"
onClick={() => handleSelect(s.id)}
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
>
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button"
onClick={handleLoadMore}
disabled={loading}
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
>
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button>
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.close")}
>
<X className="w-3.5 h-3.5" />
@@ -230,10 +230,10 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-border">
<span className="text-base font-semibold text-fg-primary tabular-nums">
{valueDisplay}
</span>
@@ -0,0 +1,115 @@
/**
* @file Unit tests for useNotifications — which websocket events raise a
* browser notification, and which preference gates each one. Covers issue #29:
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
* box once notifications are enabled, since those are the two moments the user
* is the blocker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useNotifications } from "../useNotifications";
import { eventBus } from "../../lib/eventBus";
import { NOTIF_KEY, defaultNotifPrefs, type NotifPrefs } from "../../lib/notifPrefs";
import type { DashboardEvent } from "../../lib/types";
function enable(patch: Partial<NotifPrefs> = {}) {
localStorage.setItem(
NOTIF_KEY,
JSON.stringify({ ...defaultNotifPrefs, enabled: true, ...patch })
);
}
function emit(event_type: string, summary: string) {
eventBus.publish({
type: "new_event",
timestamp: new Date().toISOString(),
data: { event_type, summary } as DashboardEvent,
});
}
let fetchMock: ReturnType<typeof vi.fn>;
describe("useNotifications", () => {
beforeEach(() => {
localStorage.clear();
// notify() posts to /api/push/send first and only falls back to a local
// Notification when that throws, so the fetch spy is what we assert on.
fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => ({ ok: true, native: true, pushed: 1 }) });
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"Notification",
Object.assign(vi.fn(), { permission: "granted" }) as unknown as typeof Notification
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function sentTitles() {
return fetchMock.mock.calls
.filter((c) => c[0] === "/api/push/send")
.map((c) => JSON.parse((c[1] as RequestInit).body as string).title as string);
}
it("notifies on a confirmation request and on the end of a turn by default", async () => {
enable();
renderHook(() => useNotifications());
emit("Notification", "Claude needs your permission to use Bash");
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
expect(sentTitles()[0]).toBe("Claude needs your confirmation");
expect(sentTitles()[1]).toBe("Claude Finished Responding");
});
it("respects each gate independently", async () => {
enable({ onAwaitingInput: false });
renderHook(() => useNotifications());
emit("Notification", "needs permission");
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
expect(sentTitles()[0]).toBe("Claude Finished Responding");
});
it("shows a local notification when the push landed nowhere", async () => {
// What a browser with no push subscription gets back: HTTP 200, zero
// deliveries. Before this, the 200 alone counted as success and the user
// saw nothing at all.
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ ok: true, native: false, pushed: 0, failed: 0 }),
});
enable();
renderHook(() => useNotifications());
emit("Stop", "Done");
await vi.waitFor(() => expect(Notification).toHaveBeenCalledTimes(1));
expect(vi.mocked(Notification).mock.calls[0]?.[0]).toBe("Claude Finished Responding");
});
it("does not double up when the push was delivered", async () => {
enable();
renderHook(() => useNotifications());
emit("Stop", "Done");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
await new Promise((r) => setTimeout(r, 20));
expect(Notification).not.toHaveBeenCalled();
});
it("stays silent while notifications are disabled", async () => {
renderHook(() => useNotifications());
emit("Notification", "needs permission");
emit("Stop", "Done");
await new Promise((r) => setTimeout(r, 20));
expect(sentTitles()).toHaveLength(0);
});
});
+166 -2
View File
@@ -1,10 +1,10 @@
/**
* @file useTheme.test.ts
* @description Tests for useTheme — the dark/light mode hook.
* @description Tests for useTheme — the dark/light/system mode and per-mode named-theme hook.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTheme } from "../useTheme";
@@ -12,9 +12,12 @@ describe("useTheme", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-mode");
});
afterEach(() => {
document.documentElement.classList.remove("dark");
vi.unstubAllGlobals();
});
it("defaults to dark when localStorage is empty", () => {
@@ -46,4 +49,165 @@ describe("useTheme", () => {
expect(result.current.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("system mode follows prefers-color-scheme, live", () => {
let dark = true;
const listeners = new Set<() => void>();
vi.stubGlobal("matchMedia", (query: string) => ({
get matches() {
return dark;
},
media: query,
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
}));
const { result } = renderHook(() => useTheme());
act(() => result.current.setMode("system"));
expect(result.current.mode).toBe("system");
expect(result.current.theme).toBe("dark");
expect(localStorage.getItem("theme")).toBe("system");
act(() => {
dark = false;
listeners.forEach((fn) => fn());
});
expect(result.current.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(document.documentElement.dataset.mode).toBe("light");
});
it("system mode without matchMedia falls back to dark", () => {
vi.stubGlobal("matchMedia", undefined);
localStorage.setItem("theme", "system");
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
});
it("each mode remembers its own named theme", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("light", "canvas"));
expect(document.documentElement.dataset.theme).toBe("darcula");
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
act(() => result.current.toggleTheme());
expect(result.current.theme).toBe("light");
expect(document.documentElement.dataset.theme).toBe("canvas");
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "canvas" });
});
it("the default theme carries no data-theme attribute", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("dark", "default"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("an unknown stored theme id falls back to default", () => {
localStorage.setItem("agenthub.theme.dark", "no-such-theme");
const { result } = renderHook(() => useTheme());
expect(result.current.themeFor.dark).toBe("default");
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("a light theme id is never applied in dark mode", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "canvas"));
expect(result.current.themeFor.dark).toBe("default");
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("two hook instances stay in sync", () => {
const a = renderHook(() => useTheme());
const b = renderHook(() => useTheme());
act(() => a.result.current.setTheme("light"));
act(() => a.result.current.setThemeFor("light", "apple-light"));
expect(b.result.current.theme).toBe("light");
expect(b.result.current.themeFor.light).toBe("apple-light");
});
it("another tab's storage write updates a mounted hook", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
act(() => {
localStorage.setItem("theme", "light");
localStorage.setItem("agenthub.theme.light", "canvas");
window.dispatchEvent(new StorageEvent("storage", { key: "theme" }));
});
expect(result.current.theme).toBe("light");
expect(document.documentElement.dataset.theme).toBe("canvas");
expect(document.documentElement.dataset.mode).toBe("light");
});
it("disabled storage (throwing) falls back to dark/default and setters do not crash", () => {
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
try {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
expect(result.current.mode).toBe("dark");
expect(result.current.themeFor).toEqual({ dark: "default", light: "default" });
expect(() => act(() => result.current.setMode("light"))).not.toThrow();
expect(() => act(() => result.current.setThemeFor("dark", "darcula"))).not.toThrow();
expect(() => act(() => result.current.toggleTheme())).not.toThrow();
expect(result.current.theme).toBe("dark");
} finally {
get.mockRestore();
set.mockRestore();
}
});
it("setThemeFor with an unknown id keeps the previous pick", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("dark", "no-such-theme"));
act(() => result.current.setThemeFor("light", ""));
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "default" });
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
expect(localStorage.getItem("agenthub.theme.light")).toBeNull();
expect(document.documentElement.dataset.theme).toBe("darcula");
});
it("an unknown stored mode falls back to dark", () => {
localStorage.setItem("theme", "blue");
const { result } = renderHook(() => useTheme());
expect(result.current.mode).toBe("dark");
expect(result.current.theme).toBe("dark");
expect(document.documentElement.dataset.mode).toBe("dark");
});
it.each([
[true, "light"],
[false, "dark"],
])("toggle from system (OS dark=%s) sets the explicit opposite: %s", (osDark, expected) => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: osDark,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
localStorage.setItem("theme", "system");
const { result } = renderHook(() => useTheme());
expect(result.current.mode).toBe("system");
act(() => result.current.toggleTheme());
expect(result.current.mode).toBe(expected);
expect(result.current.theme).toBe(expected);
expect(localStorage.getItem("theme")).toBe(expected);
});
it("unmount removes the matchMedia listener", () => {
const listeners = new Set<() => void>();
vi.stubGlobal("matchMedia", (query: string) => ({
matches: true,
media: query,
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
}));
const { unmount } = renderHook(() => useTheme());
expect(listeners.size).toBeGreaterThan(0);
unmount();
expect(listeners.size).toBe(0);
});
});
+61 -94
View File
@@ -24,6 +24,7 @@
* - `../i18n`
* - `../lib/eventBus`
* - `../lib/push`
* - `../lib/notify`
* - `../lib/types`
*
* ## Public surface
@@ -48,90 +49,14 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import i18n from "../i18n";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { subscribeToPush } from "../lib/push";
import type { WSMessage, Session, Agent, DashboardEvent } from "../lib/types";
const NOTIF_KEY = "agent-monitor-notifications";
/** User's browser-notification preferences, persisted to `localStorage` under
* {@link NOTIF_KEY} (written by the Settings page's notifications panel). */
interface NotifPrefs {
/** Master switch; when false, no notification types fire regardless of the
* per-event flags below. */
enabled: boolean;
onNewSession: boolean;
onSessionError: boolean;
onSessionComplete: boolean;
onSubagentSpawn: boolean;
}
/** Reads {@link NotifPrefs} from `localStorage`, merging over safe defaults so
* a partial/older saved object (or none at all) still yields a valid result.
* `enabled` defaults to false (opt-in) even in the "no saved value" branch,
* while individual event toggles default to a sensible starting mix. */
function loadPrefs(): NotifPrefs {
try {
const raw = localStorage.getItem(NOTIF_KEY);
if (!raw)
return {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
return {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
...JSON.parse(raw),
};
} catch {
return {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
}
}
/**
* Shows a browser notification, preferring a server-relayed push (so it can
* arrive even if this tab isn't the active one, or the browser is backgrounded)
* and falling back to a local service-worker/`Notification` call if the
* server is unreachable. No-ops when the user hasn't granted permission.
* @param title Notification title.
* @param body Notification body text.
*/
async function notify(title: string, body: string) {
if (!("Notification" in window) || Notification.permission !== "granted") return;
try {
await fetch("/api/push/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body }),
});
} catch {
// Server unreachable - fall back to local notification
try {
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, { body, icon: "/favicon.svg", silent: false });
} else {
new Notification(title, { body, icon: "/favicon.svg" });
}
} catch {
// Silently ignore
}
}
}
import { notify } from "../lib/notify";
import { loadNotifPrefs } from "../lib/notifPrefs";
import type { WSMessage, Session, Agent, DashboardEvent, Lane } from "../lib/types";
/**
* Wires the dashboard's {@link eventBus} up to browser notifications, per the
@@ -143,14 +68,41 @@ async function notify(title: string, body: string) {
* already been granted, so push delivery survives a page reload.
*/
export function useNotifications() {
// A lane's `session_id` is its current/last-run session — matching on it
// is how a click lands on the lane that actually raised the notification,
// instead of just focusing whichever browser tab was already open.
const lanesRef = useRef<Lane[]>([]);
useEffect(() => {
const prefs = loadPrefs();
const prefs = loadNotifPrefs();
if (prefs.enabled && "Notification" in window && Notification.permission === "granted") {
subscribeToPush().catch(() => {});
}
api.lanes
.list()
.then((r) => (lanesRef.current = r.lanes))
.catch(() => {});
function laneUrlForSession(sessionId: string): string | undefined {
const lane = lanesRef.current.find((l) => l.session_id === sessionId);
return lane ? `/workspace?lane=${lane.id}` : undefined;
}
return eventBus.subscribe((msg: WSMessage) => {
const prefs = loadPrefs();
if (msg.type === "lane_update") {
const payload = msg.data as { lane?: Lane; removed?: number };
if (payload.lane) {
const i = lanesRef.current.findIndex((l) => l.id === payload.lane!.id);
if (i === -1) lanesRef.current = [...lanesRef.current, payload.lane];
else lanesRef.current = lanesRef.current.map((l, idx) => (idx === i ? payload.lane! : l));
} else if (payload.removed !== undefined) {
lanesRef.current = lanesRef.current.filter((l) => l.id !== payload.removed);
}
return;
}
const prefs = loadNotifPrefs();
if (!prefs.enabled) return;
switch (msg.type) {
@@ -159,7 +111,8 @@ export function useNotifications() {
const s = msg.data as Session;
notify(
i18n.t("errors:notifications.newSession"),
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
laneUrlForSession(s.id)
);
break;
}
@@ -168,7 +121,8 @@ export function useNotifications() {
if (s.status === "error" && prefs.onSessionError) {
notify(
i18n.t("errors:notifications.sessionError"),
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
laneUrlForSession(s.id)
);
}
break;
@@ -177,26 +131,39 @@ export function useNotifications() {
if (!prefs.onSubagentSpawn) return;
const a = msg.data as Agent;
if (a.type === "subagent") {
notify(i18n.t("errors:notifications.subagentSpawned"), a.name);
notify(
i18n.t("errors:notifications.subagentSpawned"),
a.name,
laneUrlForSession(a.session_id)
);
}
break;
}
case "new_event": {
const ev = msg.data as DashboardEvent;
if (ev.event_type === "Stop" && prefs.onSessionComplete) {
const url = laneUrlForSession(ev.session_id);
// The two states where Claude is BLOCKED on the user get their own
// flags (issue #29): `Notification` is a permission prompt or a
// question, `Stop` is the end of a turn. Both used to hang off
// `onSessionComplete` / nothing at all, so the one thing the user
// actually waits for was either off by default or unswitchable.
if (ev.event_type === "Notification" && prefs.onAwaitingInput) {
notify(
i18n.t("errors:notifications.awaitingInput"),
ev.summary || i18n.t("errors:notifications.defaultBody"),
url
);
} else if (ev.event_type === "Stop" && prefs.onTurnComplete) {
notify(
i18n.t("errors:notifications.finishedResponding"),
ev.summary || i18n.t("errors:notifications.readyForInput")
ev.summary || i18n.t("errors:notifications.readyForInput"),
url
);
} else if (ev.event_type === "SessionEnd" && prefs.onSessionComplete) {
notify(
i18n.t("errors:notifications.sessionCompleted"),
ev.summary || i18n.t("errors:notifications.sessionClosed")
);
} else if (ev.event_type === "Notification") {
notify(
i18n.t("errors:notifications.defaultTitle"),
ev.summary || i18n.t("errors:notifications.defaultBody")
ev.summary || i18n.t("errors:notifications.sessionClosed"),
url
);
}
break;
+99 -24
View File
@@ -1,60 +1,135 @@
/**
* @file useTheme.ts
* @description Dark/light mode state, backed by `localStorage` and the `dark`
* class on `<html>` that Tailwind's `darkMode: "class"` reads. Default is
* dark; there is no `prefers-color-scheme` fallback.
* @description Appearance state: a mode (`dark` | `light` | `system`) plus one
* named theme remembered PER mode, all backed by `localStorage`. Applies the
* `dark` class Tailwind's `darkMode: "class"` reads, `data-mode`, and a
* `data-theme` attribute (absent for `default`) on `<html>`; the palettes
* themselves are CSS-variable blocks in `index.css`. State is never cached in
* the module — every instance reads storage + `matchMedia` through
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
* another tab's change arrives through the `storage` event.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
export type Theme = "dark" | "light";
export type ThemeMode = Theme | "system";
const STORAGE_KEY = "theme";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark"],
light: ["default", "apple-light", "intellij-light", "canvas"],
} as const satisfies Record<Theme, readonly string[]>;
function readStoredTheme(): Theme {
const MODE_KEY = "theme";
const themeKey = (mode: Theme) => `agenthub.theme.${mode}`;
const CHANGE_EVENT = "agenthub-theme";
const SYSTEM_QUERY = "(prefers-color-scheme: dark)";
function read(key: string): string | null {
try {
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
return localStorage.getItem(key);
} catch {
return "dark";
return null;
}
}
function writeStoredTheme(theme: Theme): void {
function write(key: string, value: string): void {
try {
localStorage.setItem(STORAGE_KEY, theme);
localStorage.setItem(key, value);
} catch {
/* ignore quota / disabled storage */
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}
function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle("dark", theme === "dark");
function systemQuery(): MediaQueryList | undefined {
return typeof window.matchMedia === "function" ? window.matchMedia(SYSTEM_QUERY) : undefined;
}
function readMode(): ThemeMode {
const v = read(MODE_KEY);
return v === "light" || v === "system" ? v : "dark";
}
function readThemeFor(mode: Theme): string {
const v = read(themeKey(mode));
return v && (THEMES[mode] as readonly string[]).includes(v) ? v : "default";
}
// A primitive snapshot: stable by value, so useSyncExternalStore never loops.
function getSnapshot(): string {
const mode = readMode();
const resolved: Theme =
mode === "system" ? (systemQuery()?.matches === false ? "light" : "dark") : mode;
return [mode, resolved, readThemeFor("dark"), readThemeFor("light")].join("|");
}
function subscribe(onChange: () => void): () => void {
const mq = systemQuery();
window.addEventListener(CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
mq?.addEventListener?.("change", onChange);
return () => {
window.removeEventListener(CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
mq?.removeEventListener?.("change", onChange);
};
}
function apply(resolved: Theme, name: string): void {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.dataset.mode = resolved;
if (name === "default") root.removeAttribute("data-theme");
else root.dataset.theme = name;
}
/**
* Read/write the dashboard's active color theme.
* @returns the current theme, a setter for an explicit value, and a toggle.
* Read/write the dashboard's appearance.
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
* the user picked; `themeFor` — the named theme each mode remembers; setters
* for each, and `toggleTheme`, which sets the explicit opposite of `theme`.
*/
export function useTheme(): {
theme: Theme;
mode: ThemeMode;
themeFor: Record<Theme, string>;
setTheme: (theme: Theme) => void;
setMode: (mode: ThemeMode) => void;
setThemeFor: (mode: Theme, name: string) => void;
toggleTheme: () => void;
} {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
const [mode, resolved, dark, light] = useSyncExternalStore(subscribe, getSnapshot).split("|") as [
ThemeMode,
Theme,
string,
string,
];
const active = resolved === "dark" ? dark : light;
useEffect(() => {
applyTheme(theme);
}, [theme]);
useLayoutEffect(() => {
apply(resolved, active);
}, [resolved, active]);
const setTheme = useCallback((next: Theme) => {
writeStoredTheme(next);
setThemeState(next);
const setMode = useCallback((next: ThemeMode) => write(MODE_KEY, next), []);
const setThemeFor = useCallback((forMode: Theme, name: string) => {
if ((THEMES[forMode] as readonly string[]).includes(name)) write(themeKey(forMode), name);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
setMode(resolved === "dark" ? "light" : "dark");
}, [resolved, setMode]);
return { theme, setTheme, toggleTheme };
return {
theme: resolved,
mode,
themeFor: { dark, light },
setTheme: setMode,
setMode,
setThemeFor,
toggleTheme,
};
}
+8
View File
@@ -235,6 +235,14 @@
"filterAll": "All types"
},
"profiles": {
"usageCheck": "Check quota",
"usageChecking": "Checking…",
"usageRefresh": "Refresh",
"usageRetry": "Try again",
"usageFiveHour": "5h",
"usageSevenDay": "7d",
"usageResets": "Resets {{time}}",
"usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.",
"title": "Claude Code profiles",
"openButton": "Switch profile",
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
+8 -7
View File
@@ -7,16 +7,17 @@
"goBack": "Go Back"
},
"notifications": {
"awaitingInput": "Claude needs your confirmation",
"defaultBody": "Notification",
"defaultTitle": "Claude Code",
"finishedResponding": "Claude Finished Responding",
"newSession": "New Session",
"readyForInput": "Ready for input",
"sessionClosed": "Session closed",
"sessionCompleted": "Session Completed",
"sessionDefault": "Session ",
"sessionError": "Session Error",
"subagentSpawned": "Subagent Spawned",
"finishedResponding": "Claude Finished Responding",
"readyForInput": "Ready for input",
"sessionCompleted": "Session Completed",
"sessionClosed": "Session closed",
"defaultTitle": "Claude Code",
"defaultBody": "Notification"
"subagentSpawned": "Subagent Spawned"
},
"toolLegend": {
"read": "Read",
+134 -41
View File
@@ -2,7 +2,6 @@
"action.clear": "clear state",
"action.forget": "Delete lane",
"action.purge": "delete history",
"action.remove": "delete lane + worktree",
"action.reset": "reset worktree",
"action.start": "start",
"action.stop": "stop",
@@ -17,44 +16,106 @@
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
"actions.tools": "Tools",
"actions.writeDocs": "Create/update readme file",
"actions.writeDocsHint": "Opens this lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
"actions.writeDocsResult": "Started — see this lane's terminal.",
"add": "Add lane",
"addLane": "Create a lane from a working directory",
"addLaneBaseLabel": "Branch to fork from",
"addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.",
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
"addLaneBranchExistingLabel": "Branch to check out",
"addLaneBranchHintAuto": "Leave empty to use the default name shown above.",
"addLaneBranchLabel": "New branch name",
"addLaneBranchModeCurrent": "Current branch",
"addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.",
"addLaneBranchModeExisting": "Existing branch",
"addLaneBranchModeNew": "New branch",
"addLaneBranchPlaceholder": "feat/my-feature",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
"addLanePipelineHint": "Which stages this lane's map shows. Pick the one the skill driving it declares against — nothing else in this UI changes it later.",
"addLanePipelineLabel": "Pipeline template",
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Router profile",
"addLaneSetupAgents": "Agents",
"addLaneSetupClose": "Close",
"addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Model endpoint",
"addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title",
"addLaneTitlePlaceholder": "Optional",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there.",
"addLaneWriteDocsLabel": "Create/update readme file",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
"browse.button": "Browse",
"browse.empty": "This folder is empty.",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees",
"addLaneTitlePlaceholder": "Optional",
"autoStage": "auto: {{stage}}",
"reorderHint": "Drag to reorder · Alt+←/→",
"changes.back": "All files",
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
"changes.collapse": "Collapse the changes panel",
"changes.collapseAll": "Collapse every file",
"changes.collapseAllFolders": "Collapse all folders",
"changes.collapseContext": "Show fewer surrounding lines",
"changes.collapseFile": "Collapse {{path}}",
"changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged",
"changes.conflicted": "conflict",
"changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone",
"changes.empty.branch": "This branch matches its base.",
"changes.empty.commit": "This commit changed no files.",
"changes.empty.work": "Nothing uncommitted.",
"changes.expand": "Show the changes panel",
"changes.expandAll": "Expand every file",
"changes.expandAllFolders": "Expand all folders",
"changes.expandContext": "Show more surrounding lines",
"changes.expandFile": "Expand {{path}}",
"changes.fileGone": "That file no longer has changes in this range.",
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
"changes.layout.split": "Side-by-side",
"changes.layout.unified": "Unified",
"changes.loading": "Reading the working copy…",
"changes.mode.browse": "Files",
"changes.mode.history": "History",
"changes.mode.overview": "Overview",
"changes.mode.review": "Review",
"changes.noCommits": "No commits yet.",
"changes.noHunks": "No textual change.",
"changes.noMatches": "No tracked file matches.",
"changes.openFile": "Open {{path}}",
"changes.quickOpen": "Find a file by path…",
"changes.range.branch": "vs base",
"changes.range.branchHint": "base...HEAD — everything this lane's branch adds on top of its base",
"changes.range.work": "Uncommitted",
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
"changes.refresh": "Refresh",
"changes.resize": "Resize the changes panel (next: {{size}})",
"changes.size.compact": "Narrow",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This lane's directory is not a readable git repository.",
"changes.unstage": "Unstage this file",
"changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.",
@@ -79,38 +140,83 @@
"destructive.loading": "Loading current facts…",
"destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.",
"destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.",
"destructive.notice.missing": "The lane directory is already gone. Removing it drops the dashboard's record and clears Git's stale worktree entry; nothing is deleted from disk.",
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched.",
"destructive.notice.missing": "The lane directory is already gone. Deleting the lane drops the dashboard's record only; nothing on disk and no Git record is touched.",
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. That changes nothing here: only the dashboard's record is deleted, the directory is never touched.",
"destructive.purge.confirm": "Purge history",
"destructive.purge.message": "Approve the current counts before deleting stored session history.",
"destructive.purge.title": "Purge lane history?",
"destructive.remove.confirm": "Remove lane",
"destructive.remove.message": "Approve the current counts before this action.",
"destructive.remove.title": "Remove lane?",
"destructive.remove.confirm": "Delete lane record",
"destructive.remove.message": "Only the dashboard's record of this lane is deleted. Its working tree, its branch and its database are left exactly as they are — delete them yourself if you want them gone.",
"destructive.remove.title": "Delete lane record?",
"destructive.reset.confirm": "Reset worktree",
"destructive.reset.message": "Approve the current counts before resetting this worktree.",
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
"destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"emptyState": "No lanes yet. Create one from a working directory:",
"empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
"empty.title": "No lanes yet",
"features.archived": "archived",
"features.live": "Live",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"git.filesHeading_one": "{{count}} file changed",
"git.filesHeading_other": "{{count}} files changed",
"git.filesMore_one": "… and {{count}} more file",
"git.filesMore_other": "… and {{count}} more files",
"git.noDelta": "new",
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
"gitBar.branchCreate": "Create",
"gitBar.branchCreateHint": "Create this branch without switching to it",
"gitBar.branchDelete": "Delete",
"gitBar.branchDeleteHint": "Delete the selected branch (refused if it has unmerged commits)",
"gitBar.branchNewPlaceholder": "New branch name…",
"gitBar.cancel": "Cancel",
"gitBar.commit": "Commit",
"gitBar.commitAll_one": "Commit {{count}} file",
"gitBar.commitAll_other": "Commit {{count}} files",
"gitBar.commitHint": "Stage everything and commit",
"gitBar.commitNothing": "Nothing to commit",
"gitBar.commitPlaceholder": "Commit message",
"gitBar.commitStaged": "Commit staged",
"gitBar.commitStagedHint": "Commit only what is staged in the Changes panel",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.inSync": "in sync",
"gitBar.newLane": "New lane here",
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
"gitBar.noUpstream": "not pushed",
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
"gitBar.push": "Push",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.switchCurrent": "current",
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
"gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…",
"kind.adopted": "adopted",
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"renameLabel": "Rename this lane",
"locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
"preflightError": "Could not load the current lane facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
"proof.ticketReport": "Task report",
"renameLabel": "Rename this lane",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.",
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
"router.label": "Model",
"router.restart": "Restart",
"router.restarted": "Session restarted with the new router.",
"runtime.boot": "▶ up",
"runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…",
@@ -121,30 +227,17 @@
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
"stageUndeclared": "not declared",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneCount": "{{count}} pane",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.pickLane": "Pick a lane",
"status.failed": "failed",
"status.idle": "idle",
"status.provisioning": "provisioning",
"status.running": "running",
"features.live": "Live",
"features.archived": "archived",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"proof.ticketReport": "Task report",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.paneCount": "{{count}} pane",
"splitView.pickLane": "Pick a lane",
"statusDead": "DEAD",
"title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterNative": "Native",
"addLaneRouterCustom": "AI Router",
"addLaneRouterProfileLabel": "Router profile",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneSetupRouter": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"router.label": "Model",
"router.applied": "Applied. A session already running in this lane keeps the old one until it restarts."
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees"
}
+3 -2
View File
@@ -3,7 +3,7 @@
"agentBoard": "Kanban Board",
"analytics": "Analytics",
"avg": "avg",
"brand": "Agent Dashboard",
"brand": "AgentHub",
"brandSub": "SmartGift Solution",
"ccConfig": "Claude Config",
"checkFailed": "Update check failed",
@@ -54,7 +54,8 @@
"theme": "Theme",
"themeNames": {
"dark": "Dark",
"light": "Light"
"light": "Light",
"system": "System"
},
"throughput60s": "Throughput · last 60s",
"topEventTypes": "Top event types",
+5 -2
View File
@@ -34,6 +34,7 @@
},
"errors": {
"attachFailed": "Couldn't attach to run: {{message}}",
"attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
"cwdRequired": "Working directory required to start a run",
"killFailed": "Couldn't stop: {{message}}",
"laneCreateFailed": "Failed to create lane: {{message}}",
@@ -61,6 +62,7 @@
"cwdGroups": {
"dashboard": "Dashboard server",
"home": "Home",
"repo": "Source repos",
"recent": "Recent - used by Claude Code"
},
"cwdHint": "Absolute path. Defaults to the dashboard's own cwd.",
@@ -71,7 +73,8 @@
"modelCustom": "Custom…",
"modelCustomPlaceholder": "model id (e.g. claude-opus-5)",
"modelInheritLabel": "Inherit from settings",
"permissionAcceptEdits": "acceptEdits (recommended)",
"permissionAcceptEdits": "acceptEdits (file edits without asking)",
"permissionAuto": "auto (recommended - asks only for risky actions)",
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode",
@@ -95,7 +98,7 @@
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter to send"
"shortcut": "Enter to send · Shift+Enter for newline"
},
"limitations": {
"dismiss": "Got it"
+27 -7
View File
@@ -71,21 +71,27 @@
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
},
"notifications": {
"title": "Notifications",
"awaitingInput": "Claude needs confirmation",
"blocked": "Permission blocked",
"description": "Browser notifications for important events. Requires permission.",
"disabledInfo": "Notifications are disabled - enable to get alerts for session events",
"enable": "Enable Browser Notifications",
"granted": "Permission granted",
"blocked": "Permission blocked",
"required": "Permission required",
"notifyWhen": "Notify me when...",
"newSession": "New session starts",
"notifyWhen": "Notify me when...",
"required": "Permission required",
"sendTest": "Send Test Notification",
"sessionComplete": "Session completes",
"sessionError": "Session errors",
"subagentSpawned": "Subagent spawned",
"testTitle": "Agent Monitor",
"testBlocked": "The browser has not granted notification permission.",
"testBody": "Notifications are working!",
"sendTest": "Send Test Notification",
"disabledInfo": "Notifications are disabled - enable to get alerts for session events"
"testFailed": "Nothing could be shown.",
"testLocal": "Your browser has no push subscription, so this was shown locally. Web Push needs a secure origin (localhost or HTTPS) and a registered service worker.",
"testSent": "Sent via push — check your notification centre.",
"testTitle": "Agent Monitor",
"title": "Notifications",
"turnComplete": "Claude finishes a turn"
},
"import": {
"title": "Import History",
@@ -489,5 +495,19 @@
"testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only.",
"mode": "Mode",
"modes": {
"dark": "Dark",
"light": "Light",
"system": "System"
},
"darkThemes": "Dark themes",
"lightThemes": "Light themes",
"active": "· in use now",
"default": "Default"
}
}
+8
View File
@@ -230,6 +230,14 @@
"filterAll": "Tất cả loại"
},
"profiles": {
"usageCheck": "Kiểm tra quota",
"usageChecking": "Đang kiểm tra…",
"usageRefresh": "Làm mới",
"usageRetry": "Thử lại",
"usageFiveHour": "5h",
"usageSevenDay": "7 ngày",
"usageResets": "Reset lúc {{time}}",
"usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.",
"title": "Profile Claude Code",
"openButton": "Đổi profile",
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
+8 -7
View File
@@ -7,16 +7,17 @@
"goBack": "Quay lại"
},
"notifications": {
"awaitingInput": "Claude cần bạn xác nhận",
"defaultBody": "Thông báo",
"defaultTitle": "Claude Code",
"finishedResponding": "Claude đã phản hồi xong",
"newSession": "Phiên mới",
"readyForInput": "Sẵn sàng nhận đầu vào",
"sessionClosed": "Phiên đã đóng",
"sessionCompleted": "Phiên đã hoàn tất",
"sessionDefault": "Phiên ",
"sessionError": "Lỗi phiên",
"subagentSpawned": "Đã tạo Subagent",
"finishedResponding": "Claude đã phản hồi xong",
"readyForInput": "Sẵn sàng nhận đầu vào",
"sessionCompleted": "Phiên đã hoàn tất",
"sessionClosed": "Phiên đã đóng",
"defaultTitle": "Claude Code",
"defaultBody": "Thông báo"
"subagentSpawned": "Đã tạo Subagent"
},
"toolLegend": {
"read": "Đọc",
+135 -42
View File
@@ -2,7 +2,6 @@
"action.clear": "Dọn trạng thái",
"action.forget": "Xóa lane",
"action.purge": "Xóa lịch sử",
"action.remove": "Xóa làn + worktree",
"action.reset": "Đặt lại worktree",
"action.start": "Bắt đầu",
"action.stop": "Dừng",
@@ -17,44 +16,106 @@
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
"actions.tools": "Công cụ",
"actions.writeDocs": "Tạo/cập nhật file readme",
"actions.writeDocsHint": "Mở terminal của lane này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
"actions.writeDocsResult": "Đã chạy — xem terminal của lane này.",
"add": "Thêm lane",
"addLane": "Tạo lane từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
"addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.",
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
"addLaneBranchExistingLabel": "Nhánh cần checkout",
"addLaneBranchHintAuto": "Để trống để dùng tên mặc định hiển thị ở trên.",
"addLaneBranchLabel": "Tên nhánh mới",
"addLaneBranchModeCurrent": "Nhánh hiện tại",
"addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.",
"addLaneBranchModeExisting": "Nhánh có sẵn",
"addLaneBranchModeNew": "Nhánh mới",
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
"addLanePipelineHint": "Bản đồ lane này sẽ hiện những stage nào. Chọn đúng cái mà skill điều khiển nó khai báo — sau này không màn hình nào đổi được.",
"addLanePipelineLabel": "Mẫu pipeline",
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Profile router",
"addLaneSetupAgents": "Agent",
"addLaneSetupClose": "Đóng",
"addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Endpoint model",
"addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó.",
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
"browse.button": "Duyệt",
"browse.empty": "Thư mục này trống.",
"browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree",
"addLaneTitlePlaceholder": "Không bắt buộc",
"autoStage": "tự động: {{stage}}",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"changes.back": "Tất cả file",
"changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
"changes.collapse": "Thu gọn bảng thay đổi",
"changes.collapseAll": "Thu gọn tất cả file",
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
"changes.collapseContext": "Bớt dòng xung quanh",
"changes.collapseFile": "Thu gọn {{path}}",
"changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage",
"changes.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
"changes.empty.branch": "Nhánh này không khác nhánh gốc.",
"changes.empty.commit": "Commit này không đổi file nào.",
"changes.empty.work": "Không có gì chưa commit.",
"changes.expand": "Mở bảng thay đổi",
"changes.expandAll": "Mở tất cả file",
"changes.expandAllFolders": "Mở tất cả thư mục",
"changes.expandContext": "Hiện thêm dòng xung quanh",
"changes.expandFile": "Mở {{path}}",
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
"changes.layout.split": "Hai cột",
"changes.layout.unified": "Một cột",
"changes.loading": "Đang đọc working copy…",
"changes.mode.browse": "Tệp",
"changes.mode.history": "Lịch sử",
"changes.mode.overview": "Tổng quan",
"changes.mode.review": "Xem toàn bộ",
"changes.noCommits": "Chưa có commit nào.",
"changes.noHunks": "Không có thay đổi nội dung.",
"changes.noMatches": "Không có file nào khớp.",
"changes.openFile": "Mở {{path}}",
"changes.quickOpen": "Tìm file theo đường dẫn…",
"changes.range.branch": "So nhánh gốc",
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của lane này thêm vào so với nhánh gốc",
"changes.range.work": "Chưa commit",
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
"changes.refresh": "Tải lại",
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
"changes.size.compact": "Hẹp",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của lane này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index",
"changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
@@ -78,39 +139,84 @@
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.",
"destructive.loading": "Đang tải trạng thái hiện tại…",
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.",
"destructive.notice.adopted": "Làn đường này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
"destructive.notice.missing": "Thư mục làn đường đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển và dọn mục worktree cũ trong Git; không tệp nào bị xóa khỏi đĩa.",
"destructive.notice.unreadable": "Không thể đọc thư mục làn đường như một Git worktree. Hệ thống sẽ thử xóa; nếu Git tự chối, mục ghi worktree của nó sẽ được dọn trực tiếp thay thế. Trong mọi trường hợp, thư mục vẫn không bị thay đổi.",
"destructive.notice.adopted": "Lane này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
"destructive.notice.missing": "Thư mục lane đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển; không tệp nào trên đĩa và không bản ghi Git nào bị đụng tới.",
"destructive.notice.unreadable": "Không thể đọc thư mục lane như một Git worktree. Điều đó không ảnh hưởng gì: chỉ bản ghi trên bảng điều khiển bị xóa, thư mục không bao giờ bị đụng tới.",
"destructive.purge.confirm": "Xóa lịch sử",
"destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.",
"destructive.purge.title": "Xóa lịch sử làn đường?",
"destructive.remove.confirm": "Xóa làn đường",
"destructive.remove.message": "Hãy xác nhận các số liệu hiện tại trước khi thực hiện thao tác này.",
"destructive.remove.title": "Xóa làn đường?",
"destructive.remove.confirm": "Xóa bản ghi lane",
"destructive.remove.message": "Chỉ bản ghi của lane trên bảng điều khiển bị xóa. Working tree, nhánh và cơ sở dữ liệu của lane được giữ nguyên — bạn tự xóa nếu thực sự muốn bỏ chúng.",
"destructive.remove.title": "Xóa bản ghi lane?",
"destructive.reset.confirm": "Đặt lại worktree",
"destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.",
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
"destructive.reset.title": "Đặt lại worktree được quản lý?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
"empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
"empty.title": "Chưa có lane nào",
"features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"git.filesHeading_one": "{{count}} tệp thay đổi",
"git.filesHeading_other": "{{count}} tệp thay đổi",
"git.filesMore_one": "… và {{count}} tệp nữa",
"git.filesMore_other": "… và {{count}} tệp nữa",
"git.noDelta": "mới",
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
"gitBar.branchCreate": "Tạo",
"gitBar.branchCreateHint": "Tạo nhánh này mà không chuyển sang",
"gitBar.branchDelete": "Xoá",
"gitBar.branchDeleteHint": "Xoá nhánh đang chọn (git từ chối nếu còn commit chưa merge)",
"gitBar.branchNewPlaceholder": "Tên nhánh mới…",
"gitBar.cancel": "Huỷ",
"gitBar.commit": "Commit",
"gitBar.commitAll_one": "Commit {{count}} file",
"gitBar.commitAll_other": "Commit {{count}} file",
"gitBar.commitHint": "Stage tất cả rồi commit",
"gitBar.commitNothing": "Không có gì để commit",
"gitBar.commitPlaceholder": "Nội dung commit",
"gitBar.commitStaged": "Commit phần đã stage",
"gitBar.commitStagedHint": "Chỉ commit những file đã stage ở thẻ Changes",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.inSync": "đã đồng bộ",
"gitBar.newLane": "Tạo lane mới",
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
"gitBar.noUpstream": "chưa push",
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
"gitBar.push": "Push",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.switchCurrent": "hiện tại",
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…",
"kind.adopted": "đã nhận",
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"renameLabel": "Đổi tên làn đường này",
"locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
"proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên làn đường này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.",
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
"router.label": "Model",
"router.restart": "Khởi động lại",
"router.restarted": "Đã khởi động lại phiên với router mới.",
"runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…",
@@ -121,30 +227,17 @@
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
"stageUndeclared": "chưa khai báo",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneCount": "{{count}} ô",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.pickLane": "Chọn lane",
"status.failed": "thất bại",
"status.idle": "rảnh",
"status.provisioning": "đang khởi tạo",
"status.running": "đang chạy",
"features.live": "Phiên bản trực tiếp",
"features.archived": "đã lưu trữ",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"proof.ticketReport": "Báo cáo nhiệm vụ",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.paneCount": "{{count}} ô",
"splitView.pickLane": "Chọn lane",
"statusDead": "ĐÃ CHẾT",
"title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterNative": "Native",
"addLaneRouterCustom": "AI Router",
"addLaneRouterProfileLabel": "Profile router",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneSetupRouter": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"router.label": "Model",
"router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại."
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree"
}
+3 -2
View File
@@ -3,7 +3,7 @@
"agentBoard": "Bảng Kanban",
"analytics": "Phân tích",
"avg": "TB",
"brand": "Bảng điều khiển Agent",
"brand": "AgentHub",
"brandSub": "SmartGift Solution",
"ccConfig": "Cấu hình Claude",
"checkFailed": "Kiểm tra cập nhật thất bại",
@@ -54,7 +54,8 @@
"theme": "Giao diện",
"themeNames": {
"dark": "Tối",
"light": "Sáng"
"light": "Sáng",
"system": "Theo hệ thống"
},
"throughput60s": "Lưu lượng · 60s qua",
"topEventTypes": "Loại sự kiện phổ biến",
+5 -2
View File
@@ -33,6 +33,7 @@
},
"errors": {
"attachFailed": "Không gắn được vào run: {{message}}",
"attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
"cwdRequired": "Cần thư mục làm việc để bắt đầu run",
"killFailed": "Không dừng được: {{message}}",
"laneCreateFailed": "Không thể tạo làn đường: {{message}}",
@@ -60,6 +61,7 @@
"cwdGroups": {
"dashboard": "Dashboard server",
"home": "Home",
"repo": "Repo nguồn",
"recent": "Gần đây - Claude Code đã dùng"
},
"cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.",
@@ -70,7 +72,8 @@
"modelCustom": "Tuỳ chỉnh…",
"modelCustomPlaceholder": "model id (vd: claude-opus-5)",
"modelInheritLabel": "Kế thừa từ settings",
"permissionAcceptEdits": "acceptEdits (khuyên dùng)",
"permissionAcceptEdits": "acceptEdits (sửa file không hỏi)",
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Permission mode",
@@ -94,7 +97,7 @@
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
},
"limitations": {
"dismiss": "Đã rõ"
+27 -7
View File
@@ -71,21 +71,27 @@
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
},
"notifications": {
"title": "Thông báo",
"awaitingInput": "Claude cần xác nhận",
"blocked": "Đã chặn quyền",
"description": "Thông báo trình duyệt cho các sự kiện quan trọng. Cần cấp quyền.",
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên",
"enable": "Bật thông báo trình duyệt",
"granted": "Đã cấp quyền",
"blocked": "Đã chặn quyền",
"required": "Cần cấp quyền",
"notifyWhen": "Thông báo cho tôi khi...",
"newSession": "Phiên mới bắt đầu",
"notifyWhen": "Thông báo cho tôi khi...",
"required": "Cần cấp quyền",
"sendTest": "Gửi thông báo thử",
"sessionComplete": "Phiên hoàn tất",
"sessionError": "Phiên gặp lỗi",
"subagentSpawned": "Subagent được tạo",
"testTitle": "Agent Monitor",
"testBlocked": "Trình duyệt chưa cấp quyền thông báo.",
"testBody": "Thông báo đang hoạt động!",
"sendTest": "Gửi thông báo thử",
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên"
"testFailed": "Không hiển thị được thông báo nào.",
"testLocal": "Trình duyệt chưa đăng ký push nên thông báo được hiện tại chỗ. Web Push cần origin bảo mật (localhost hoặc HTTPS) và service worker đã đăng ký.",
"testSent": "Đã gửi qua push — kiểm tra trung tâm thông báo.",
"testTitle": "Agent Monitor",
"title": "Thông báo",
"turnComplete": "Claude trả lời xong"
},
"import": {
"title": "Nhập lịch sử",
@@ -489,5 +495,19 @@
"testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}"
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
"mode": "Chế độ",
"modes": {
"dark": "Tối",
"light": "Sáng",
"system": "Theo hệ thống"
},
"darkThemes": "Theme tối",
"lightThemes": "Theme sáng",
"active": "· đang dùng",
"default": "Mặc định"
}
}
+261 -24
View File
@@ -15,35 +15,86 @@
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
* 3-5 component background 9-10 solid (buttons, vibrant fills)
* 11-12 text (11 secondary, 12 primary)
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
* and terracotta, because a cool near-white page reads as glare. `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
*/
:root {
:root,
[data-mode="light"] {
/* Light theme — the CSS default, so it applies with no class on <html>.
Radix `slate` (light), steps 1/2/3/4/5/6/7. */
--surface-0: 252 252 253; /* slate-1 #fcfcfd */
--surface-1: 249 249 251; /* slate-2 #f9f9fb */
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */
--surface-3: 232 232 236; /* slate-4 #e8e8ec */
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
--border: 217 217 224; /* slate-6 #d9d9e0 */
--border-light: 205 206 214; /* slate-7 #cdced6 */
--accent: 0 144 255; /* blue-9 #0090ff */
--accent-hover: 5 136 240; /* blue-10 #0588f0 */
--fg-primary: 28 32 36; /* slate-12 #1c2024 */
--fg-secondary: 96 100 108; /* slate-11 #60646c */
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
/* Status: green/red/amber step 11 (light) — the same "readable text" step
as fg-secondary, so success/danger/warning sit at the same visual weight
as ordinary secondary text, not shouting over it. */
--status-success: 33 131 88; /* green-11 #218358 */
--status-danger: 206 44 49; /* red-11 #ce2c31 */
--status-warning: 171 100 0; /* amber-11 #ab6400 */
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
page (the Settings appearance cards).
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
as it does in dark: the page is the DARKEST step and a card is brighter
than the page it sits on. The previous light block used the scale in its
native direction, which inverted the whole language — a near-white page
(glare) under grey cards that read as recessed, and every `hover:bg-*`
darkening instead of lifting.
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
A cool near-white ramp kept reading as glare no matter how far the page
step was pulled down, because the brightest thing on screen was also the
bluest. Cream lowers the perceived glare at the same measured lightness
and gives every neutral a warm cast to sit against. The whole ramp sits
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
white: at full brightness a large light panel still tires the eye. */
--surface-0: 232 229 218; /* #e8e5da — page background */
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
--border: 207 201 184; /* #cfc9b8 */
--border-light: 222 217 204; /* #ded9cc */
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer. */
--accent: 163 74 40; /* #a34a28 */
--accent-hover: 142 63 32; /* #8e3f20 */
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
sits a clear step below fg-secondary. */
--fg-muted: 104 99 89; /* #686359 */
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
over cream. The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
the card; these also clear it against the status's OWN densest chip (a
20% tint of itself — 4.5:1), which is where the label actually sits. */
--status-success: 22 104 66; /* #166842 */
--status-danger: 170 36 41; /* #aa2429 */
--status-warning: 133 79 0; /* #854f00 */
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
--tint-base: var(--surface-3);
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 4 120 87; /* emerald-700 #047857 */
--syn-number: 194 65 12; /* orange-700 #c2410c */
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
--syn-function: 180 83 9; /* amber-700 #b45309 */
--syn-operator: 190 24 93; /* pink-700 #be185d */
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
}
.dark {
@@ -70,6 +121,187 @@
--status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
--tint-base: var(--surface-3);
/* Unchanged from the pastels this file has always drawn code in. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
--syn-number: 253 186 116; /* orange-300 #fdba74 */
--syn-keyword: 196 181 253; /* violet-300 #c4b5fd */
--syn-builtin: 125 211 252; /* sky-300 #7dd3fc */
--syn-function: 254 240 138; /* yellow-200 #fef08a */
--syn-operator: 249 168 212; /* pink-300 #f9a8d4 */
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
--syn-tag: 253 164 175; /* rose-300 #fda4af */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
--syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */
}
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
* Settings → Appearance). Each overrides only what differs from its mode's
* default block above; `--tint-base` and light `--syn-plain` are derived there.
* Dark ones pair with `.dark`, light ones with `[data-mode="light"]`, so a
* theme can never paint over the wrong mode whatever order the attributes
* land in. Contrast is held to the defaults' own bar: fg-muted ≥4.5:1 on a
* light card, status labels ≥4.5:1 on their own /20 chip in light themes. */
.dark[data-theme="darcula"] {
--surface-0: 30 31 32; /* #1e1f20 */
--surface-1: 35 36 37; /* #232425 */
--surface-2: 39 41 42; /* #27292a */
--surface-3: 43 43 43; /* #2b2b2b */
--surface-4: 51 53 55; /* #333537 */
--surface-5: 60 63 65; /* #3c3f41 */
--border: 63 66 68; /* #3f4244 */
--border-light: 77 81 83; /* #4d5153 */
--accent: 53 146 196; /* #3592c4 */
--accent-hover: 74 163 214; /* #4aa3d6 */
--fg-primary: 220 220 220; /* #dcdcdc */
--fg-secondary: 169 183 198; /* #a9b7c6 */
--fg-muted: 140 146 150; /* #8c9296 */
--status-success: 126 194 135; /* #7ec287 */
--status-danger: 255 143 140; /* #ff8f8c */
--status-warning: 232 191 106; /* #e8bf6a */
--syn-comment: 128 128 128; /* #808080 */
--syn-string: 127 159 105; /* #7f9f69 */
--syn-number: 104 151 187; /* #6897bb */
--syn-keyword: 204 120 50; /* #cc7832 */
--syn-builtin: 136 136 198; /* #8888c6 */
--syn-function: 255 198 109; /* #ffc66d */
--syn-operator: 169 183 198; /* #a9b7c6 */
--syn-punctuation: 169 183 198; /* #a9b7c6 */
--syn-property: 168 139 192; /* #a88bc0 */
--syn-tag: 232 191 106; /* #e8bf6a */
--syn-add: 106 171 115; /* #6aab73 */
--syn-del: 255 107 104; /* #ff6b68 */
--syn-plain: 169 183 198; /* #a9b7c6 */
}
.dark[data-theme="apple-dark"] {
--surface-0: 22 22 24; /* #161618 */
--surface-1: 28 28 30; /* #1c1c1e */
--surface-2: 36 36 38; /* #242426 */
--surface-3: 44 44 46; /* #2c2c2e */
--surface-4: 54 54 56; /* #363638 */
--surface-5: 58 58 60; /* #3a3a3c */
--border: 56 56 58; /* #38383a */
--border-light: 72 72 74; /* #48484a */
--accent: 10 132 255; /* #0a84ff */
--accent-hover: 64 156 255; /* #409cff */
--fg-primary: 245 245 247; /* #f5f5f7 */
--fg-secondary: 174 174 178; /* #aeaeb2 */
--fg-muted: 142 142 147; /* #8e8e93 */
--status-success: 48 209 88; /* #30d158 */
--status-danger: 255 140 134; /* #ff8c86 */
--status-warning: 255 214 10; /* #ffd60a */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
--syn-number: 208 191 105; /* #d0bf69 */
--syn-keyword: 252 95 163; /* #fc5fa3 */
--syn-builtin: 93 216 255; /* #5dd8ff */
--syn-function: 103 183 164; /* #67b7a4 */
--syn-operator: 161 161 166; /* #a1a1a6 */
--syn-punctuation: 161 161 166; /* #a1a1a6 */
--syn-property: 181 140 240; /* #b58cf0 */
--syn-tag: 252 95 163; /* #fc5fa3 */
--syn-add: 48 209 88; /* #30d158 */
--syn-del: 255 105 97; /* #ff6961 */
--syn-plain: 229 229 234; /* #e5e5ea */
}
[data-mode="light"][data-theme="apple-light"] {
--surface-0: 233 233 238; /* #e9e9ee */
--surface-1: 239 239 244; /* #efeff4 */
--surface-2: 245 245 248; /* #f5f5f8 */
--surface-3: 251 251 253; /* #fbfbfd */
--surface-4: 253 253 254; /* #fdfdfe */
--surface-5: 255 255 255; /* #ffffff */
--border: 209 209 214; /* #d1d1d6 */
--border-light: 229 229 234; /* #e5e5ea */
--accent: 0 102 204; /* #0066cc */
--accent-hover: 0 85 179; /* #0055b3 */
--fg-primary: 29 29 31; /* #1d1d1f */
--fg-secondary: 81 81 84; /* #515154 */
--fg-muted: 110 110 115; /* #6e6e73 */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
--status-success: 23 107 46; /* #176b2e */
--status-danger: 179 0 14; /* #b3000e */
--status-warning: 122 79 0; /* #7a4f00 */
--syn-comment: 93 108 121; /* #5d6c79 */
--syn-string: 196 26 22; /* #c41a16 */
--syn-number: 28 0 207; /* #1c00cf */
--syn-keyword: 155 35 147; /* #9b2393 */
--syn-builtin: 11 79 121; /* #0b4f79 */
--syn-function: 50 109 116; /* #326d74 */
--syn-operator: 75 75 80; /* #4b4b50 */
--syn-punctuation: 75 75 80; /* #4b4b50 */
--syn-property: 57 0 160; /* #3900a0 */
--syn-tag: 155 35 147; /* #9b2393 */
--syn-add: 31 122 54; /* #1f7a36 */
--syn-del: 197 0 15; /* #c5000f */
}
[data-mode="light"][data-theme="intellij-light"] {
--surface-0: 236 238 242; /* #eceef2 */
--surface-1: 241 242 245; /* #f1f2f5 */
--surface-2: 247 248 250; /* #f7f8fa */
--surface-3: 253 253 254; /* #fdfdfe */
--surface-4: 255 255 255; /* #ffffff */
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 47 106 227; /* #2f6ae3 */
--accent-hover: 39 88 192; /* #2758c0 */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 108 112 126; /* #6c707e */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
}
[data-mode="light"][data-theme="canvas"] {
--surface-0: 227 220 205; /* #e3dccd */
--surface-1: 232 226 212; /* #e8e2d4 */
--surface-2: 237 232 220; /* #ede8dc */
--surface-3: 243 239 230; /* #f3efe6 */
--surface-4: 247 244 237; /* #f7f4ed */
--surface-5: 250 248 243; /* #faf8f3 */
--border: 203 191 166; /* #cbbfa6 */
--border-light: 220 211 192; /* #dcd3c0 */
--accent: 122 82 48; /* #7a5230 */
--accent-hover: 102 63 34; /* #663f22 */
--fg-primary: 43 38 32; /* #2b2620 */
--fg-secondary: 94 85 74; /* #5e554a */
--fg-muted: 107 97 85; /* #6b6155 */
--status-success: 54 93 36; /* #365d24 */
--status-danger: 147 41 31; /* #93291f */
--status-warning: 109 76 0; /* #6d4c00 */
--syn-comment: 125 114 98; /* #7d7262 */
--syn-string: 77 107 31; /* #4d6b1f */
--syn-number: 154 91 0; /* #9a5b00 */
--syn-keyword: 138 59 18; /* #8a3b12 */
--syn-builtin: 47 94 110; /* #2f5e6e */
--syn-function: 122 90 0; /* #7a5a00 */
--syn-operator: 138 48 80; /* #8a3050 */
--syn-punctuation: 107 97 85; /* #6b6155 */
--syn-property: 59 102 96; /* #3b6660 */
--syn-tag: 142 47 47; /* #8e2f2f */
--syn-add: 77 107 31; /* #4d6b1f */
--syn-del: 159 47 35; /* #9f2f23 */
}
@layer base {
@@ -102,12 +334,17 @@
}
::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
/* The browser draws this glyph near-black. Inverting it is a DARK-mode fix;
applied globally it painted a white icon onto a white field. */
.dark ::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
}
::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
+214 -14
View File
@@ -396,10 +396,16 @@ import type {
CostResult,
DashboardEvent,
Lane,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneFileContent,
LaneTreeEntry,
LaneCounts,
LaneGitFacts,
LaneRuntime,
LaneTool,
LaneWorktree,
ModelPricing,
NamedLock,
PipelineStats,
@@ -500,7 +506,11 @@ export function dashboardToken(): string | null {
* @returns The parsed JSON response body, typed as `T`.
* @throws {Error} When the response status is not ok (non-2xx). The thrown
* message is `body.error.message` if the error body parsed as JSON and carried
* one, otherwise the literal `HTTP <status>`.
* one, otherwise the literal `HTTP <status>`. The server's own `error.code`
* (e.g. `"ERUNLIVE"`) rides along as `.code` on the thrown error — every
* route's error body carries one (see `server/routes/*.js`), and a caller
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = dashboardToken();
@@ -517,7 +527,10 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `HTTP ${res.status}`);
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
});
}
return res.json();
}
@@ -944,7 +957,14 @@ export const api = {
node_version: string;
platform: string;
ws_connections: number;
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
memory: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
heapLimit?: number;
};
cpu_load: number[];
arch: string;
total_mem: number;
@@ -1584,6 +1604,21 @@ export const api = {
"/cc-config/profiles/activate",
{ method: "POST", body: JSON.stringify({ name }) }
),
/**
* POST /api/cc-config/profiles/usage - how much of this profile's account
* quota is already spent.
*
* A POST because answering it is a write: a saved profile's access token has
* almost always expired, so the server refreshes it against Anthropic first
* and the refresh rotates the credentials stored on disk. That is also why
* this is never called on a timer — only when the user asks for one profile.
* No token material crosses the boundary in either direction.
*/
profileUsage: (name: string) =>
request<CcProfileUsage>("/cc-config/profiles/usage", {
method: "POST",
body: JSON.stringify({ name }),
}),
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
deleteProfile: (name: string) =>
request<{ ok: true; name: string }>("/cc-config/profiles", {
@@ -2262,7 +2297,14 @@ export const api = {
* location isn't tied to a specific project (e.g. Sagi's mascot lane).
* @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created.
*/
ensure: (body: { cwd?: string; title?: string; pipeline?: string; tool?: LaneTool }) =>
ensure: (body: {
cwd?: string;
title?: string;
pipeline?: string;
tool?: LaneTool;
/** Only a lane on THIS directory counts, not one on a parent of it. */
exact?: boolean;
}) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
method: "POST",
body: JSON.stringify(body),
@@ -2289,11 +2331,13 @@ export const api = {
/**
* GET /api/lanes/branches — a candidate source repo's local branches.
* @param repo Absolute path to an existing git repository.
* @returns `{ branches, current }` — local branch names (not `origin/*`)
* and which one is currently checked out (null if detached).
* @returns `{ branches, current, worktrees }` — local branch names (not
* `origin/*`), which one is currently checked out (null if detached),
* and the repo's existing worktrees, so the picker can grey out the
* branches git would refuse to check out a second time.
*/
branches: (repo: string) =>
request<{ branches: string[]; current: string | null }>(
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
`/lanes/branches?repo=${encodeURIComponent(repo)}`
),
/**
@@ -2342,6 +2386,129 @@ export const api = {
* readable git repository, which is a normal state rather than an error.
*/
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
/**
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
* the Changes panel asks for.
* @param id The lane id.
* @param opts `range` picks the comparison (`work` = uncommitted vs HEAD,
* `branch` = base...HEAD). Pass `path` for one file's hunks, or
* `full: true` for every file's; with neither it is a summary.
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
*/
changes: (
id: number,
opts: {
range?: LaneChangeRange;
path?: string;
full?: boolean;
sha?: string;
context?: number;
} = {}
) => {
const q = new URLSearchParams();
if (opts.range) q.set("range", opts.range);
if (opts.path) q.set("path", opts.path);
if (opts.full) q.set("full", "1");
if (opts.sha) q.set("sha", opts.sha);
if (opts.context != null) q.set("context", String(opts.context));
return request<LaneChanges>(`/lanes/${id}/git/changes?${q.toString()}`);
},
/**
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
* @param id The lane id.
* @param opts `file` narrows the list to the commits touching one path.
*/
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
return request<{ available: boolean; commits: LaneCommit[] }>(
`/lanes/${id}/git/log?${q.toString()}`
);
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
* @param id The lane id.
* @param path Repo-relative path.
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
*/
gitFile: (id: number, path: string, rev = "work") =>
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
/**
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
* quick-open. Ignored files never appear (it reads `git ls-files`).
* @param id The lane id.
* @param q The substring typed into the quick-open box.
*/
gitLs: (id: number, q: string) =>
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
`/lanes/${id}/git/ls?q=${encodeURIComponent(q)}`
),
/**
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
* @param id The lane id.
* @param path Repo-relative directory; "" for the repo root.
*/
gitTree: (id: number, path = "") =>
request<{
available: boolean;
path: string;
entries: LaneTreeEntry[];
truncated: boolean;
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`),
/**
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
* the branch switcher. The repo comes from the lane row, never the query.
* @param id The lane id.
*/
gitBranches: (id: number) =>
request<{
available: boolean;
branches: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(`/lanes/${id}/git/branches`),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
* `branch-delete`.
* @param body `{message, only}` for commit; `{branch, create}` for switch;
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
* `{paths, confirm: true}` for discard — which is irreversible, so the
* server refuses it without that flag.
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
*/
gitAction: (
id: number,
action:
| "fetch"
| "pull"
| "push"
| "commit"
| "switch"
| "stage"
| "unstage"
| "discard"
| "branch-create"
| "branch-delete",
body: {
message?: string;
only?: "staged";
branch?: string;
base?: string;
create?: boolean;
paths?: string[];
confirm?: boolean;
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string }>(
`/lanes/${id}/git/${action}`,
{ method: "POST", body: JSON.stringify(body) }
),
/**
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
* ports, service liveness, and the last boot error.
@@ -2483,7 +2650,14 @@ export const api = {
* profile NAME comes from here.
*/
routerApply: (id: number, profile: string) =>
request<{ tool: string; file: string | null; profile: string }>(`/lanes/${id}/router/apply`, {
request<{
tool: string;
file: string | null;
profile: string;
/** A pane is live and keeps the old models until it restarts. */
restart_required: boolean;
run_id: string | null;
}>(`/lanes/${id}/router/apply`, {
method: "POST",
body: JSON.stringify({ profile }),
}),
@@ -2656,6 +2830,25 @@ export interface CcProfile extends CcAccountIdentity {
active: boolean;
}
/** One quota window as POST /api/cc-config/profiles/usage reports it. */
export interface CcUsageWindow {
/** Percent of the window's allowance already spent, 0-100. */
utilization: number | null;
/** ISO timestamp the window rolls over at, when the account reports one. */
resetsAt: string | null;
}
/** An account's spent quota, for the profile switcher. Percentages and reset
* times only — the OAuth token stays on the server. */
export interface CcProfileUsage {
name: string;
/** True when the stored token had expired and was refreshed to answer this. */
refreshed: boolean;
fiveHour: CcUsageWindow | null;
sevenDay: CcUsageWindow | null;
checkedAt: string;
}
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
@@ -3349,7 +3542,7 @@ export interface CodexMarketplaceActionArgs {
// ─────────────────────────────────────────────────────────────────────────────
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions";
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
@@ -3435,7 +3628,7 @@ export interface DashboardRunHistoryItem {
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
* = previously used for a run. */
kind: "dashboard" | "home" | "recent";
kind: "dashboard" | "home" | "recent" | "repo";
path: string;
label: string;
}
@@ -3474,10 +3667,17 @@ export const RUN_EFFORT_CHOICES: EffortChoice[] = [
// configured default and the rest map to concrete `--model` values.
export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" },
{ id: "claude-opus-5", label: "Opus 5", hint: "Highest capability" },
{ id: "claude-sonnet-5", label: "Sonnet 5", hint: "Balanced capability and speed" },
{ id: "claude-fable-5", label: "Fable 5", hint: "Fast output, same capability tier as Opus" },
{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", hint: "Fastest, lightest" },
// Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/
// `sonnet`/`haiku` as "an alias for the latest model", and the CLI resolves
// each one itself (2.1.278 maps fable -> claude-fable-5-1). A pinned id here
// is a list that silently goes stale every release and has to be edited by
// hand; an alias follows whatever `claude` was last updated to. Cost math is
// unaffected — pricing keys off the model the transcript reports, which is
// the resolved full id.
{ id: "fable", label: "Fable (latest)", hint: "Fast output, same capability tier as Opus" },
{ id: "opus", label: "Opus (latest)", hint: "Highest capability" },
{ id: "sonnet", label: "Sonnet (latest)", hint: "Balanced capability and speed" },
{ id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" },
];
// Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent
+22 -19
View File
@@ -1033,46 +1033,49 @@ export function highlight(source: string, lang: string): Token[] {
/**
* Map a token type to a Tailwind colour class for rendering.
* @param type A {@link TokenType} produced by {@link highlight}.
* @returns Tailwind utility classes (text colour, and a background tint for
* diff add/remove lines); falls back to a neutral gray for "plain"/unknown.
* @returns Tailwind utility classes built on the `syntax.*` THEME TOKENS
* (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
* pastel one this file has always used, and light mode takes the same hues
* four steps darker, because a pastel keyword on a light code block is
* unreadable. Diff rows take a /20 status tint, pre-mixed per theme.
*/
export function tokenClass(type: TokenType): string {
switch (type) {
case "comment":
return "text-gray-500 italic"; // muted + italic to recede visually
return "text-syntax-comment italic"; // muted + italic to recede visually
case "string":
return "text-emerald-300"; // green, shared with diff-add text
return "text-syntax-string"; // green, shared with diff-add text
case "number":
return "text-orange-300"; // orange, shared with boolean literals
return "text-syntax-number"; // orange, shared with boolean literals
case "keyword":
return "text-violet-300"; // violet, shared with diff-meta headers
return "text-syntax-keyword"; // violet, shared with diff-meta headers
case "builtin":
return "text-sky-300";
return "text-syntax-builtin";
case "function":
return "text-yellow-200"; // yellow, shared with HTML attr names
return "text-syntax-function"; // yellow, shared with HTML attr names
case "operator":
return "text-pink-300";
return "text-syntax-operator";
case "punctuation":
return "text-gray-400"; // slightly brighter than comments, dimmer than plain
return "text-syntax-punctuation"; // brighter than comments, dimmer than plain
case "property":
return "text-cyan-300";
return "text-syntax-property";
case "tag":
return "text-rose-300";
return "text-syntax-tag";
case "attr":
return "text-yellow-200"; // matches "function" colour by design
return "text-syntax-function"; // matches "function" colour by design
case "variable":
return "text-amber-300";
return "text-syntax-function"; // amber family, same as functions/attrs
case "boolean":
return "text-orange-300"; // literals grouped with numbers by colour
return "text-syntax-number"; // literals grouped with numbers by colour
case "diff-add":
return "text-emerald-300 bg-emerald-500/10"; // green text + faint green row tint
return "text-syntax-add bg-status-success/20"; // green text + theme-aware green row tint
case "diff-del":
return "text-red-300 bg-red-500/10"; // red text + faint red row tint
return "text-syntax-del bg-status-danger/20"; // red text + theme-aware red row tint
case "diff-meta":
return "text-violet-300";
return "text-syntax-keyword";
case "plain":
default:
return "text-gray-200"; // default body text colour
return "text-syntax-plain"; // default body text colour
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* @file Browser-notification preferences — the single definition of the shape,
* the defaults, and the `localStorage` read/write. Settings (which edits them)
* and `useNotifications` (which obeys them) both import from here; they used to
* carry private copies, so adding a notification type meant editing the same
* literal twice and a miss went unnoticed until a toggle did nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const NOTIF_KEY = "agent-monitor-notifications";
export interface NotifPrefs {
/** Master switch; when false no notification fires, whatever the flags say. */
enabled: boolean;
onNewSession: boolean;
onSessionError: boolean;
/** Claude is BLOCKED on the user — a permission prompt or a question. */
onAwaitingInput: boolean;
/** Claude finished its turn and is waiting for the next prompt (`Stop`). */
onTurnComplete: boolean;
/** The whole session closed (`SessionEnd`), not just one turn. */
onSessionComplete: boolean;
onSubagentSpawn: boolean;
}
// The two "Claude is waiting for you" types default ON: they are the reason
// notifications exist (issue #29 — the user walks away during a long run and
// needs to know when they are the blocker). The rest stay as they were.
export const defaultNotifPrefs: NotifPrefs = {
enabled: false,
onNewSession: false,
onSessionError: true,
onAwaitingInput: true,
onTurnComplete: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
/** Reads prefs, merged over the defaults so a stored blob written by an older
* build (missing the newer flags) still yields a complete object. */
export function loadNotifPrefs(): NotifPrefs {
try {
const raw = localStorage.getItem(NOTIF_KEY);
if (!raw) return { ...defaultNotifPrefs };
return { ...defaultNotifPrefs, ...(JSON.parse(raw) as Partial<NotifPrefs>) };
} catch {
return { ...defaultNotifPrefs };
}
}
export function saveNotifPrefs(prefs: NotifPrefs) {
try {
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
} catch {
/* private mode / quota — the toggle still applies for this tab */
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* @file Shows a browser notification — the single `notify()` both
* `useNotifications` (live events) and Settings' "Send Test Notification"
* button call, so the test proves the real delivery path and can report
* where it actually landed instead of a bare fire-and-forget.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export type NotifyVia = "native" | "push" | "local" | "blocked" | "none";
export interface NotifyError {
statusCode?: number;
message?: string;
}
export interface NotifyResult {
via: NotifyVia;
errors: NotifyError[];
}
/**
* Prefers a server-relayed push (so it can arrive even if this tab isn't the
* active one, or the browser is backgrounded) and falls back to a local
* service-worker/`Notification` call when the server didn't deliver.
* @param title Notification title.
* @param body Notification body text.
* @param url Same-origin path to focus/navigate to on click (e.g. the lane
* that raised this notification). Omit for notifications with no target.
*/
export async function notify(title: string, body: string, url?: string): Promise<NotifyResult> {
if (!("Notification" in window) || Notification.permission !== "granted") {
return { via: "blocked", errors: [] };
}
const errors: NotifyError[] = [];
// A 200 from /api/push/send means the request was ACCEPTED, not that anything
// reached the user: with no push subscription (no service worker, a
// non-secure origin, a browser that never subscribed) or a push service the
// server cannot reach, it answers `{ok:true, native:false, pushed:0}` and
// nobody is notified. Only `native` or `pushed > 0` is actual delivery —
// anything else falls through to the local notification below, which is why
// the fallback is keyed on the RESULT rather than on fetch throwing.
try {
const res = await fetch("/api/push/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, url }),
});
const result = res.ok ? await res.json().catch(() => null) : null;
if (result?.native) return { via: "native", errors };
if (result && result.pushed > 0) return { via: "push", errors };
if (!res.ok) errors.push({ statusCode: res.status });
} catch (e) {
errors.push({ message: e instanceof Error ? e.message : String(e) });
}
try {
// Vector, not `/favicon.ico` (32x32 raster) — crisp at whatever size the
// OS renders it, matching the icon server/lib/push.js already sends.
const data = url ? { url } : undefined;
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
body,
icon: "/favicon.svg",
silent: false,
data,
});
} else {
const n = new Notification(title, { body, icon: "/favicon.svg", data });
if (url) {
// No service worker to catch the click here — navigate this tab directly.
n.onclick = () => {
window.focus();
window.location.assign(url);
};
}
}
return { via: "local", errors };
} catch (e) {
errors.push({ message: e instanceof Error ? e.message : String(e) });
return { via: "none", errors };
}
}
+113
View File
@@ -2594,6 +2594,15 @@ export type LaneGitFacts =
untracked: number;
/** The files behind the counts, capped; the counts themselves stay exact. */
files: LaneGitFile[];
/** Whether the repo has any remote configured at all. */
hasRemote: boolean;
/** `origin/<branch>`, or null when this branch has never been pushed. */
upstream: string | null;
/** Commits HEAD has that the upstream does not; null without an upstream
* — not 0, which would claim "in sync" about a comparison never made. */
ahead: number | null;
/** Commits the upstream has that HEAD does not; null without an upstream. */
behind: number | null;
}
| { available: false };
@@ -2609,6 +2618,110 @@ export interface LaneGitFile {
deleted: number | null;
}
/** One entry of `git worktree list` for a repo. */
export interface LaneWorktree {
/** Absolute path of the worktree's directory. */
path: string;
/** The branch checked out there; absent for a detached worktree. */
branch?: string;
locked?: boolean;
}
/** Which comparison the Changes panel is showing. */
export type LaneChangeRange = "work" | "branch" | "commit";
/** One row of `GET /api/lanes/:id/git/log`. */
export interface LaneCommit {
sha: string;
short: string;
subject: string;
author: string;
/** ISO 8601 author date. */
date: string;
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
refs: string[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
export type LaneFileContent =
| {
available: true;
path: string;
rev: string;
binary: boolean;
truncated: boolean;
lines: string[];
}
| { available: false };
/** One entry of `GET /api/lanes/:id/git/tree` — a folded directory or a file. */
export interface LaneTreeEntry {
name: string;
/** Path relative to the repo root. */
path: string;
dir: boolean;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
export interface LaneDiffLine {
type: "ctx" | "add" | "del";
/** Line number on the left (before) side; null on an added line. */
oldLine: number | null;
/** Line number on the right (after) side; null on a removed line. */
newLine: number | null;
text: string;
}
/** One `@@` block of a file's diff. */
export interface LaneDiffHunk {
header: string;
/** The function/section name git puts after the `@@`, when it found one. */
context: string;
oldStart: number;
newStart: number;
lines: LaneDiffLine[];
}
/** One changed file, as `GET /api/lanes/:id/git/changes` reports it. */
export interface LaneChangeFile {
path: string;
/** Two-character porcelain code for the `work` range, `"M "`-style for `branch`. */
status: string;
untracked: boolean;
/** The file has changes in the index (`work` range only). */
staged?: boolean;
/** The file has changes only in the working tree (`work` range only). */
unstaged?: boolean;
/** The file is in a merge conflict (`work` range only). */
conflicted?: boolean;
added: number | null;
deleted: number | null;
binary: boolean;
/** Absent in overview mode (no `full`/`path`), `[]` for a binary file. */
hunks?: LaneDiffHunk[];
/** This file's patch was cut short by a size cap. */
truncated?: boolean;
}
/** `GET /api/lanes/:id/git/changes`. */
export type LaneChanges =
| {
available: true;
range: LaneChangeRange;
/** The lane's base branch name, for `range: "branch"`. */
base: string | null;
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
baseRef: string | null;
/** The commit being shown, for `range: "commit"`; its full sha. */
sha: string | null;
/** Lines of unchanged context each hunk carries. */
context: number;
files: LaneChangeFile[];
/** A cap bit: the file list or some patch was cut short. */
truncated: boolean;
}
| { available: false; range: LaneChangeRange; files: LaneChangeFile[] };
/** Aggregate counts across all lanes. */
export interface LaneCounts {
total: number;
+134
View File
@@ -141,6 +141,8 @@ import type {
CcUpdateAllBatch,
CcProfile,
CcAccountIdentity,
CcProfileUsage,
CcUsageWindow,
CcScope,
CcSettingsSource,
CcStatusline,
@@ -4232,6 +4234,18 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
/** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
/** Quota per profile, keyed by `name`, fetched for every profile once the
* list arrives — the whole point of the panel is comparing accounts, and one
* button per row made the user do that by hand. The cost is bounded: the
* live account is read from the CLI and never refreshed, and any other
* profile is refreshed only when its token has actually expired, which
* leaves it valid for hours of further opens. */
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
/** Per profile, so one slow account does not freeze the other rows. */
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const load = useCallback(async () => {
try {
@@ -4276,6 +4290,42 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
[load, onToast]
);
const checkUsage = useCallback(async (name: string) => {
setUsageBusy((prev) => ({ ...prev, [name]: true }));
setUsageError((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
try {
const result = await api.ccConfig.profileUsage(name);
setUsage((prev) => ({ ...prev, [name]: result }));
} catch (err: unknown) {
setUsageError((prev) => ({
...prev,
[name]: err instanceof Error ? err.message : "unknown error",
}));
} finally {
setUsageBusy((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}
}, []);
// Every profile's quota, as soon as there is a list to ask about. Once per
// profile per opening: a retry is the row's own button, so a failed account
// is not re-asked on every render.
useEffect(() => {
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage]);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
@@ -4441,6 +4491,13 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
@@ -4459,6 +4516,83 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
);
}
/**
* One profile's quota: a button until asked, then the two windows Anthropic
* reports — the 5-hour session allowance and the 7-day one.
*
* Deliberately not fetched with the profile list. Reading a saved profile's
* usage means refreshing its OAuth token, and that rotates the credentials on
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
* on a render or a poll.
*/
function ProfileUsageLine({
usage,
error,
busy,
disabled,
onCheck,
}: {
usage?: CcProfileUsage;
error?: string;
busy: boolean;
disabled: boolean;
onCheck: () => void;
}) {
const { t } = useTranslation("ccConfig");
const pct = (w: CcUsageWindow | null | undefined) =>
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
const resetTitle = (w: CcUsageWindow | null | undefined) =>
w && w.resetsAt
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
: undefined;
if (error) {
return (
<div className="mt-1 flex items-center gap-2 text-[10px]">
<span className="text-status-danger">{error}</span>
<button
onClick={onCheck}
disabled={disabled}
className="text-fg-muted hover:text-accent disabled:opacity-50"
>
{t("profiles.usageRetry")}
</button>
</div>
);
}
if (!usage) {
return (
<button
onClick={onCheck}
disabled={disabled}
title={t("profiles.usageHint")}
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
</button>
);
}
return (
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
<span title={resetTitle(usage.fiveHour)}>
{t("profiles.usageFiveHour")}{" "}
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
</span>
<span title={resetTitle(usage.sevenDay)}>
{t("profiles.usageSevenDay")}{" "}
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
</span>
<button
onClick={onCheck}
disabled={disabled}
className="hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
</button>
</div>
);
}
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
+14 -7
View File
@@ -132,7 +132,14 @@ interface SystemInfo {
node_version: string;
platform: string;
ws_connections: number;
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
memory: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
heapLimit?: number;
};
cpu_load: number[];
arch: string;
total_mem: number;
@@ -169,7 +176,7 @@ function ChartTooltip({ x, y, children }: { x: number; y: number; children: Reac
const nearRight = x > window.innerWidth - 200;
return (
<div
className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
className="fixed z-50 px-2 py-1.5 text-xs bg-surface-2 border border-border rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? x - 14 : x + 14,
top: y - 10,
@@ -1562,10 +1569,10 @@ function SystemHealthTab() {
const memUsedPct =
info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0;
const heapUsedPct =
info.server.memory.heapTotal > 0
? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100
: 0;
// Against the heap LIMIT: heapTotal is only what V8 has grown to so far, so
// used/total reads 70-90% on a healthy process and dragged the health score.
const heapMax = info.server.memory.heapLimit || info.server.memory.heapTotal;
const heapUsedPct = heapMax > 0 ? (info.server.memory.heapUsed / heapMax) * 100 : 0;
return {
totalEntries,
@@ -1701,7 +1708,7 @@ function SystemHealthTab() {
</Tip>
<Tip
block
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapLimit || info.server.memory.heapTotal)} limit\nAllocated: ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
+52 -45
View File
@@ -59,6 +59,7 @@ import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragmen
import { useTranslation } from "react-i18next";
import {
DollarSign,
Palette,
Plus,
Pencil,
Trash2,
@@ -71,6 +72,8 @@ import {
AlertTriangle,
RotateCcw,
CheckCircle,
HelpCircle,
MessageSquare,
XCircle,
Server,
Bell,
@@ -109,11 +112,14 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { sagiPrefs } from "../components/Sagi/prefs";
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
import { notify } from "../lib/notify";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import type { ModelPricing, WSMessage } from "../lib/types";
// In-page navigation for the (dense) Settings screen. Each entry maps to a
@@ -124,6 +130,7 @@ const SETTINGS_SECTIONS: {
fallback?: string;
Icon: typeof DollarSign;
}[] = [
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
@@ -142,40 +149,6 @@ const SETTINGS_SECTIONS: {
{ id: "about", labelKey: "about.title", Icon: Server },
];
// ─── Notification preferences ───
const NOTIF_KEY = "agent-monitor-notifications";
interface NotifPrefs {
enabled: boolean;
onNewSession: boolean;
onSessionError: boolean;
onSessionComplete: boolean;
onSubagentSpawn: boolean;
}
const defaultNotif: NotifPrefs = {
enabled: false,
onNewSession: true,
onSessionError: true,
onSessionComplete: false,
onSubagentSpawn: false,
};
function loadNotifPrefs(): NotifPrefs {
try {
const raw = localStorage.getItem(NOTIF_KEY);
if (!raw) return { ...defaultNotif };
return { ...defaultNotif, ...JSON.parse(raw) };
} catch {
return { ...defaultNotif };
}
}
function saveNotifPrefs(prefs: NotifPrefs) {
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
}
// ─── Helpers ───
interface EditRow {
@@ -399,7 +372,7 @@ function PricingInfoTooltip() {
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: pos.left, top: pos.top, width: 320 }}
>
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
@@ -600,7 +573,10 @@ export function Settings() {
const perm = await Notification.requestPermission();
if (perm === "granted") {
updateNotifPrefs({ enabled: true });
await subscribeToPush();
// A browser with no service worker / on a non-secure origin rejects here;
// notifications still work through the local fallback in useNotifications,
// so this must not blow up the enable flow.
await subscribeToPush().catch(() => {});
}
};
@@ -1107,6 +1083,8 @@ export function Settings() {
</div>
</div>
<AppearanceSettings />
{/* ─── MODEL PRICING ─── */}
<section id="pricing" className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
@@ -1577,6 +1555,14 @@ export function Settings() {
{t("notifications.notifyWhen")}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<HelpCircle className="w-4 h-4 text-status-warning flex-shrink-0" />
<Toggle
checked={notifPrefs.onAwaitingInput}
onChange={(v) => updateNotifPrefs({ onAwaitingInput: v })}
label={t("notifications.awaitingInput")}
/>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<Play className="w-4 h-4 text-status-success flex-shrink-0" />
<Toggle
@@ -1585,6 +1571,14 @@ export function Settings() {
label={t("notifications.newSession")}
/>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<MessageSquare className="w-4 h-4 text-accent flex-shrink-0" />
<Toggle
checked={notifPrefs.onTurnComplete}
onChange={(v) => updateNotifPrefs({ onTurnComplete: v })}
label={t("notifications.turnComplete")}
/>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<CheckCircle className="w-4 h-4 text-violet-400 flex-shrink-0" />
<Toggle
@@ -1614,15 +1608,27 @@ export function Settings() {
<div className="pt-3 border-t border-border">
<button
onClick={async () => {
if (!("Notification" in window) || Notification.permission !== "granted")
return;
await fetch("/api/push/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: t("notifications.testTitle"),
body: t("notifications.testBody"),
}),
// Same notify() the live events use, so the test proves the
// real path — and reports WHERE it landed, since a push that
// reaches nobody used to look exactly like success.
const r = await notify(
t("notifications.testTitle"),
t("notifications.testBody")
);
const reason = r.errors
.map((e) => [e.statusCode, e.message].filter(Boolean).join(" "))
.join("; ");
setActionResult({
key: "notif-test",
isError: r.via === "none" || r.via === "blocked",
message:
r.via === "push" || r.via === "native"
? t("notifications.testSent")
: r.via === "local"
? t("notifications.testLocal")
: r.via === "blocked"
? t("notifications.testBlocked")
: `${t("notifications.testFailed")}${reason ? ` (${reason})` : ""}`,
});
}}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors"
@@ -1630,6 +1636,7 @@ export function Settings() {
<Zap className="w-3 h-3" />
{t("notifications.sendTest")}
</button>
<div className="mt-2">{actionBanner(["notif-test"])}</div>
</div>
</div>
)}
+2 -2
View File
@@ -435,10 +435,10 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3.5 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
className="fixed z-50 p-3.5 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-[#2a2a4a]">
<p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-border">
{title}
</p>
+446 -252
View File
@@ -41,8 +41,19 @@
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Plus, Square, Columns2, Grid2x2, Pencil } from "lucide-react";
import {
Plus,
Square,
Columns2,
Grid2x2,
Pencil,
Layers,
PanelLeftClose,
ChevronsLeftRight,
PanelRightClose,
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
@@ -64,10 +75,36 @@ import LaneCard, {
} from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
/** localStorage key for the Changes panel's open/closed state. */
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
* narrow → 50/50 → 70/30 → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "wide"] as const;
type ChangesSize = (typeof CHANGES_SIZES)[number];
const CHANGES_WIDTH: Record<ChangesSize, string> = {
compact: "w-[28rem] xl:w-[34rem]",
half: "w-1/2",
wide: "w-[70%]",
};
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, a wider panel, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
compact: Columns2,
half: ChevronsLeftRight,
wide: PanelRightClose,
};
// ── Page ──────────────────────────────────────────────────────────────
/** One pane, two side by side, four in a grid — the conventional icons for
@@ -198,12 +235,19 @@ function ConsoleArea({
export function Workspace() {
const { t: tLanes } = useTranslation("lanes");
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const [searchParams, setSearchParams] = useSearchParams();
// Lane state
const [lanes, setLanes] = useState<Lane[]>([]);
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
// Fields the Add-lane modal opens with when it was opened from a lane's git
// bar ("new lane here"): one provisioning path for every lane, prefilled.
const [addLanePrefill, setAddLanePrefill] = useState<{
sourceRepo?: string;
branch?: string;
} | null>(null);
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]);
const [pipelineTemplates, setPipelineTemplates] = useState<
@@ -237,6 +281,44 @@ export function Workspace() {
// single-pane view and collapsed in split view, but the toggle below can
// override that in either mode — the user decides, not the layout.
const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1);
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
// order (client/src/lib/laneOrderStorage.ts).
const [changesOpen, setChangesOpen] = useState(() => {
try {
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
} catch {
/* private mode / disabled storage: the panel just forgets between visits */
}
}, [changesOpen]);
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
try {
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null;
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact";
} catch {
return "compact";
}
});
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize);
} catch {
/* same as above: a forgotten width, not an error */
}
}, [changesSize]);
const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel =
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const showInfo = infoExpanded;
const setLayout = useCallback((layout: SplitLayout) => {
@@ -274,6 +356,26 @@ export function Workspace() {
}
}, []);
// Deep link from a notification click (`/workspace?lane=<id>`, see
// useNotifications' laneUrlForSession): once that lane is in the loaded
// list, select it and drop the param so a later manual lane switch sticks.
useEffect(() => {
const laneParam = searchParams.get("lane");
if (laneParam === null || lanes.length === 0) return;
const paramId = Number(laneParam);
if (lanes.some((l) => l.id === paramId)) {
setSelectedLaneId(paramId);
}
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("lane");
return next;
},
{ replace: true }
);
}, [lanes, searchParams, setSearchParams]);
useEffect(() => {
Promise.all([api.run.binary("claude"), api.run.binary("codex")])
.then(([claude, codex]) => setBinaryStatus({ claude, codex }))
@@ -552,37 +654,67 @@ export function Workspace() {
scrolling row (holds any number of lanes), the detail/console area
below it gets the full width and never competes for it. */}
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
{/* "Add lane" is a tile in the strip rather than a header button:
creating a lane is the same kind of act as picking one, so it
reads as the empty slot at the head of the row. Dashed border and
no liveness dot keep it from being mistaken for a lane. */}
{lanes.length === 0 ? (
/* With no lanes the strip was a dashed tile beside a stray sentence,
which reads as a half-rendered row rather than a state. One banner
instead — and a compact one on purpose: the run setup form below
is what the user actually needs the height for. */
<button
type="button"
data-testid="lane-tile-add"
data-testid="lane-empty"
onClick={() => setAddLaneOpen(true)}
title={tLanes("addLane")}
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
>
<Plus className="h-4 w-4" />
{tLanes("add")}
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
<Layers className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<span className="block text-sm font-medium text-fg-primary">
{tLanes("empty.title")}
</span>
<p className="mt-0.5 text-xs text-fg-muted">
{tLanes("empty.body")}{" "}
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
agenthub lanes add --cwd $(pwd)
</code>
</p>
</div>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
<Plus className="h-4 w-4" />
{tLanes("add")}
</span>
</button>
{lanes.map((l, i) => (
<LaneStripCard
key={l.id}
lane={l}
index={i}
selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)}
onReorder={handleLaneReorder}
/>
))}
{!lanes.length && (
<p className="text-sm text-fg-muted">
{tLanes("emptyState")} <code>agenthub lanes add --cwd $(pwd)</code>
</p>
)}
</div>
) : (
<div
data-testid="lane-strip"
className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
>
{/* "Add lane" is a tile in the strip rather than a header button:
creating a lane is the same kind of act as picking one, so it
reads as the empty slot at the head of the row. Dashed border and
no liveness dot keep it from being mistaken for a lane. */}
<button
type="button"
data-testid="lane-tile-add"
onClick={() => setAddLaneOpen(true)}
title={tLanes("addLane")}
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
{tLanes("add")}
</button>
{lanes.map((l, i) => (
<LaneStripCard
key={l.id}
lane={l}
index={i}
selected={selectedLaneId === l.id}
onSelect={() => setSelectedLaneId(l.id)}
onReorder={handleLaneReorder}
/>
))}
</div>
)}
{/* The selected lane's pipeline — the thing you actually come to this
page to read, beside the list rather than stacked under it.
@@ -593,11 +725,17 @@ export function Workspace() {
selection) unmounted the terminal and wiped a half-typed prompt.
Same rule as RunConsole's own collapse — hide with CSS, never by
unmounting. */}
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
>
{/* One header row, not two. The pipeline picker, the stage badges,
{/* Detail and the Changes panel share one row: the panel is a reading
surface for the same lane the console is driving, so it sits beside
it rather than under it. It collapses to a rail instead of
unmounting nothing — there is no live subscription inside it, so
unmounting is safe and keeps a closed panel from polling. */}
<div className="flex min-h-0 flex-1 gap-4">
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
>
{/* One header row, not two. The pipeline picker, the stage badges,
the feature picker and the router select used to sit on a second
row below this one; every one of them is a per-lane control that
belongs beside the lane's own name, and stacking them cost a whole
@@ -605,245 +743,297 @@ export function Workspace() {
down. The pipeline's NAME is no longer interpolated into
`laneHeader` either — the picker right next to it already says it.
They stay gated on `showInfo`, same as before the merge. */}
{currentLane && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
{currentLane && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
prefix any more: the id is on the strip tile, where the CLI's
`--lane` reader looks for it, and repeating it here only ate
the width the name needed. Submitting an empty name clears
`title`, which puts the cwd back in its place. */}
{renamingLaneId === currentLane.id ? (
<input
data-testid="lane-rename-input"
aria-label={tLanes("renameLabel")}
autoFocus
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
// Blur CANCELS, it does not save — same convention as the
// rename field in CcConfig. Committing here too would fire
// the PATCH twice for one Enter: the commit unmounts the
// focused input, and removing a focused element raises a
// native blur before the refetch has moved `lanes`, so the
// "unchanged title" guard would not catch the second call.
onBlur={() => setRenamingLaneId(null)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(currentLane.id);
if (e.key === "Escape") setRenamingLaneId(null);
}}
maxLength={200}
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
/>
) : (
<button
type="button"
data-testid="lane-rename"
onClick={() => {
setRenameDraft(currentLane.title || "");
setRenamingLaneId(currentLane.id);
}}
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
>
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
{showInfo && (
<>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
{renamingLaneId === currentLane.id ? (
<input
data-testid="lane-rename-input"
aria-label={tLanes("renameLabel")}
autoFocus
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
// Blur CANCELS, it does not save — same convention as the
// rename field in CcConfig. Committing here too would fire
// the PATCH twice for one Enter: the commit unmounts the
// focused input, and removing a focused element raises a
// native blur before the refetch has moved `lanes`, so the
// "unchanged title" guard would not catch the second call.
onBlur={() => setRenamingLaneId(null)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(currentLane.id);
if (e.key === "Escape") setRenamingLaneId(null);
}}
maxLength={200}
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
/>
) : (
<button
type="button"
data-testid="lane-rename"
onClick={() => {
setRenameDraft(currentLane.title || "");
setRenamingLaneId(currentLane.id);
}}
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
>
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
<LaneRouterRow lane={currentLane} />
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `agenthub stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
>
{tLanes("autoStage", { stage: currentLane.detected_stage })}
</span>
)}
{features.length > 0 && (
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
{showInfo && (
<>
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
<option value="">{tLanes("features.live")}</option>
{features.map((f) => (
<option key={f.slug} value={f.slug}>
{f.slug}
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
{/* Per-lane actions (agents install, mcp sync, sync-base check)
<LaneRouterRow lane={currentLane} />
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
one as its amber badge — so the chips were a second,
smaller copy of the same two facts. */}
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
>
<option value="">{tLanes("features.live")}</option>
{features.map((f) => (
<option key={f.slug} value={f.slug}>
{f.slug}
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
</option>
))}
</select>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
{/* Per-lane actions (agents install, mcp sync, sync-base check)
belong beside the lane they act on, not in a page header. */}
<LaneToolsMenu lane={currentLane} />
<button
type="button"
data-testid="lane-detail-toggle"
aria-expanded={infoExpanded}
onClick={() => setInfoExpanded((v) => !v)}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
>
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
</button>
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
{showInfo && (
<LaneToolsMenu lane={currentLane} />
<button
type="button"
data-testid="lane-action-remove"
onClick={() => setRemoveConfirmOpen(true)}
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
data-testid="lane-detail-toggle"
aria-expanded={infoExpanded}
onClick={() => setInfoExpanded((v) => !v)}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
>
{currentLane.kind === "adopted"
? tLanes("action.forget")
: tLanes("action.remove")}
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
</button>
)}
</div>
</div>
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3">
<LaneCard
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
{showInfo && (
<button
type="button"
data-testid="lane-action-remove"
onClick={() => setRemoveConfirmOpen(true)}
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
>
{/* One label for both kinds: removal forgets the lane's row
and never deletes a worktree, a branch or a database. */}
{tLanes("action.forget")}
</button>
)}
onSelectLane={setSelectedLaneId}
/>
</div>
</div>
<div className="mb-3">
<PipelineMap
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
>
{tLanes("proof.ticketReport")}
</a>
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3">
<LaneCard
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
)}
{Object.entries(proofFeature.groups).map(([group, images]) => (
<div key={group} className="mt-1">
<span className="text-xs text-fg-muted">
{group} · {images.length}
</span>
<div className="flex flex-wrap gap-1">
{images.slice(0, 8).map((img) => (
<img
key={img}
loading="lazy"
className="h-16 w-16 rounded object-cover"
src={api.lanes.proof.imageUrl(
currentLane.id,
proofFeature.slug,
group,
img
)}
alt={img}
/>
))}
{images.length > 8 && (
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
)}
onSelectLane={setSelectedLaneId}
onNewLaneFromRepo={(repo, branch) => {
setAddLanePrefill({ sourceRepo: repo, branch });
setAddLaneOpen(true);
}}
/>
</div>
<div className="mb-3">
<PipelineMap
nodes={
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
>
{tLanes("proof.ticketReport")}
</a>
)}
{Object.entries(proofFeature.groups).map(([group, images]) => (
<div key={group} className="mt-1">
<span className="text-xs text-fg-muted">
{group} · {images.length}
</span>
<div className="flex flex-wrap gap-1">
{images.slice(0, 8).map((img) => (
<img
key={img}
loading="lazy"
className="h-16 w-16 rounded object-cover"
src={api.lanes.proof.imageUrl(
currentLane.id,
proofFeature.slug,
group,
img
)}
alt={img}
/>
))}
{images.length > 8 && (
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
<div
className={`flex min-h-0 flex-1 flex-col gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{/* `showToggle`: the lane header carries its own split toggle; with
))}
</div>
)}
</div>
)}
<div
className={`flex min-h-0 flex-1 flex-col gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead. */}
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
/>
</div>
</section>
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
/>
</div>
</section>
{currentLane &&
(changesOpen ? (
<aside
id="lane-changes-panel"
data-testid="lane-changes-card"
className={`card flex shrink-0 flex-col p-3 ${CHANGES_WIDTH[changesSize]}`}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
{tLanes("changes.title")}
</span>
<button
type="button"
data-testid="lane-changes-resize"
onClick={() => {
// Past the widest step the next state is "gone", which
// is what the rail button reopens at `compact`.
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) {
setChangesOpen(false);
setChangesSize("compact");
} else {
setChangesSize(nextChangesSize);
}
}}
// The control names the same region the rail button does,
// so a screen reader reads one panel that is open or
// closed rather than two unrelated buttons.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={changesNextLabel}
title={changesNextLabel}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
{/* Icon-only, and it draws the width the click LANDS on,
not the current one — the label the tooltip carries. */}
{(() => {
const Next = CHANGES_NEXT_ICON[changesSize];
return <Next className="h-4 w-4" />;
})()}
</button>
</div>
<LaneChangesPanel laneId={currentLane.id} />
</aside>
) : (
<button
type="button"
data-testid="lane-changes-expand"
onClick={() => setChangesOpen(true)}
aria-expanded={false}
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.expand")}
title={tLanes("changes.expand")}
className="card flex w-9 shrink-0 flex-col items-center gap-2 py-3 text-fg-muted transition-colors hover:text-fg-primary"
>
<PanelLeftClose className="h-4 w-4" />
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
{tLanes("changes.title")}
</span>
</button>
))}
</div>
</div>
{currentLane && removeConfirmOpen && (
@@ -862,7 +1052,11 @@ export function Workspace() {
<AddLaneModal
open={addLaneOpen}
cwdSuggestions={cwdSuggestions}
onClose={() => setAddLaneOpen(false)}
prefill={addLanePrefill}
onClose={() => {
setAddLaneOpen(false);
setAddLanePrefill(null);
}}
onAdded={(lane) => {
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
setSelectedLaneId(lane.id);
@@ -0,0 +1,146 @@
/**
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
* it asks for EVERY saved account's quota, not one row at a time.
*
* The one-at-a-time version made the user click through their own accounts to
* answer the only question the panel exists for — which account still has room.
* The other half is that each row fails on its own: an account whose saved
* login has been superseded must not stop its neighbours from reporting.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
const window = (utilization: number) => ({
utilization,
resetsAt: "2026-09-18T08:00:00Z",
});
return {
window,
overview: r({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/repo/.claude",
projectRoot: "/repo",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 0,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
marketplaces: r({ marketplaces: [] }),
mcp: r({ mcp: [] }),
hooks: r({ hooks: [] }),
settings: r({ sources: [] }),
keybindings: r({ groups: [] }),
statusline: r({}),
hookScripts: r({ items: [] }),
backups: r({ items: [] }),
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
// The modal's own list: two saved accounts, one of them live.
profiles: r({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
],
active: { accountUuid: "u1", email: "a@b.c" },
}),
profileUsage: vi.fn().mockImplementation(async (name: string) => {
if (name === "spare") {
throw new Error("this profile's saved login has been superseded");
}
return {
name,
refreshed: false,
fiveHour: window(8),
sevenDay: window(70),
checkedAt: "2026-09-17T16:00:00Z",
};
}),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CcConfig } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function openProfiles() {
render(<CcConfig />);
await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle();
await settle();
}
afterEach(() => vi.clearAllMocks());
describe("CcConfig — auth profile quotas", () => {
it("asks for every saved account's quota when the modal opens", async () => {
await openProfiles();
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
expect(await screen.findByText("8%")).toBeInTheDocument();
expect(await screen.findByText("70%")).toBeInTheDocument();
});
it("reports the account that failed without costing the others their numbers", async () => {
await openProfiles();
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
expect(screen.getByText("8%")).toBeInTheDocument();
});
it("asks once per profile, however many times the panel re-renders", async () => {
await openProfiles();
await settle();
await settle();
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
expect(asked).toHaveLength(1);
});
});
@@ -0,0 +1,182 @@
/**
* @file Workspace.changesPanel.test.tsx
* @description The Changes panel is ONE control: it steps narrow → 50/50 →
* 70/30 → closed, and the rail reopens it narrow. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, cleanup, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import i18n from "i18next";
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
// fixtures it reads have to be lifted with it.
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
const laneStub = (id: number, cwd: string) => ({
id,
title: `lane-${id}`,
cwd,
branch: null,
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
});
return {
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
LANE_A: laneStub(1, "/workspace/alpha"),
LANE_B: laneStub(2, "/workspace/beta"),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
run: {
list: r({ items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [HOME] }),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running" }),
},
lanes: {
list: r({
lanes: [LANE_A, LANE_B],
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Workspace — the Changes panel's width", () => {
beforeEach(() => window.localStorage.clear());
async function openPanel() {
render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
// Open/closed is itself remembered, so the second mount of a test comes up
// already open — clicking the rail then would close it.
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
}
it("steps narrow → 50/50 → 70/30 → closed on one control", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[28rem]");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-1/2");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-[70%]");
// Past the widest step the panel closes — and reopens narrow, not back at
// 70/30, which is why closing resets the width.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(card().className).toContain("w-[28rem]");
});
it("remembers the width across a reload, and never asks the server", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
cleanup();
await openPanel();
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
});
it("keeps the rail as the only way back in", async () => {
await openPanel();
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
});
});
@@ -0,0 +1,223 @@
/**
* @file Workspace.laneRouter.test.tsx
* @description The Model (AI Router) picker in the Workspace lane-detail
* header, exercised through the page rather than through `LaneRouterRow` on its
* own — one row instance serves every lane, so the bug this pins is a lane
* switch carrying the previous lane's selection with it: pick a router on one
* lane, come back to a lane that is on native, and see the router still
* selected there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
router_profile: string | null;
};
const lane = (id: number, title: string): LaneFixture => ({
id,
cwd: `/workspace/${id}`,
title,
pipeline: "default",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
router_profile: null,
});
const h = vi.hoisted(() => {
const nativeProfile = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
return {
lanes: [] as { id: number; router_profile: string | null }[],
routerApply: vi.fn(),
profiles: [
nativeProfile,
{ ...nativeProfile, name: "9router", label: "9Router", builtin: false },
],
};
});
const routerApply = h.routerApply;
let lanesToReturn: LaneFixture[] = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => ({
lanes: h.lanes,
counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 },
})),
ensure: vi.fn(),
action: vi.fn(),
git: vi.fn().mockResolvedValue({ available: false }),
runtime: vi.fn().mockResolvedValue({ available: false }),
stage: vi.fn(),
update: vi.fn(),
pipelines: vi
.fn()
.mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }),
// The route the picker must use — never a lane PATCH.
routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => {
h.routerApply(id, profile);
// The row moving with the call is the server's broadcast, not a test
// convenience: the apply route broadcasts the lane so the cached row
// stops disagreeing with what was just written. Drop the broadcast
// server-side and the next lane switch re-reads a stale profile.
const row = h.lanes.find((l) => l.id === id);
if (row) row.router_profile = profile === "native" ? null : profile;
return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile };
}),
features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() },
proof: {
list: vi.fn().mockResolvedValue({ features: [] }),
imageUrl: vi.fn(),
delete: vi.fn(),
},
},
aiRouters: {
list: vi.fn().mockImplementation(async () => ({ items: h.profiles })),
},
run: {
list: vi.fn().mockResolvedValue({ runs: [], items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockResolvedValue({ items: [] }),
files: vi.fn().mockResolvedValue({ items: [] }),
start: vi.fn(),
get: vi.fn(),
send: vi.fn(),
kill: vi.fn(),
},
ccConfig: {
commands: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
routerApply.mockClear();
lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")];
h.lanes = lanesToReturn as unknown as typeof h.lanes;
});
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
/** Click the strip tile for a lane, which is how a lane is selected. */
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
const tile = screen.getAllByText(title)[0];
if (!tile) throw new Error(`no strip tile for ${title}`);
await user.click(tile);
await settle();
}
describe("Workspace — the lane Model picker across a lane switch", () => {
it("leaves a lane on native after another lane was pointed at a router", async () => {
await renderWorkspace();
const user = userEvent.setup();
await selectLane(user, "Lane A");
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
await selectLane(user, "Lane B");
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
await settle();
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
// Back to the lane that was never switched. It is still native server-side,
// so the picker must say so — the row is one instance shared by every lane.
await selectLane(user, "Lane A");
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
// And nothing may have been written to lane A on the way.
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
});
});
File diff suppressed because it is too large Load Diff
+54
View File
@@ -8,6 +8,43 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* A status/accent FILL is pre-mixed against one fixed base, never translucent.
*
* `bg-status-success/10` used to emit `rgb(var(--status-success) / 0.1)`, which
* composites with whatever is behind it — so the one "success" chip rendered as
* five different colours over the five surface steps, and washed out badly on
* the light theme's near-white cards. Mixing against `--tint-base` (the card
* surface of the active theme) gives an OPAQUE colour: the same chip reads the
* same on the page, in a card, in a modal and inside an input row.
*
* Applied to `backgroundColor`/`borderColor` only. `text-status-warning/80` and
* `ring-accent/40` stay genuinely translucent — text and rings sit on top of a
* fill rather than replacing it — and the surface family keeps its alpha too,
* or every modal scrim would turn solid.
*/
const tint =
(variable) =>
({ opacityValue }) => {
const alpha = Number(opacityValue);
// No modifier at all: Tailwind passes `var(--tw-bg-opacity, 1)` here.
if (!Number.isFinite(alpha) || alpha >= 1) return `rgb(var(${variable}))`;
return `color-mix(in srgb, rgb(var(${variable})) ${alpha * 100}%, rgb(var(--tint-base)))`;
};
const tinted = {
accent: {
DEFAULT: tint("--accent"),
hover: tint("--accent-hover"),
muted: "color-mix(in srgb, rgb(var(--accent)) 15%, rgb(var(--tint-base)))",
},
status: {
success: tint("--status-success"),
danger: tint("--status-danger"),
warning: tint("--status-warning"),
},
};
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
@@ -37,12 +74,29 @@ export default {
secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
muted: "rgb(var(--fg-muted) / <alpha-value>)",
},
syntax: {
comment: "rgb(var(--syn-comment) / <alpha-value>)",
string: "rgb(var(--syn-string) / <alpha-value>)",
number: "rgb(var(--syn-number) / <alpha-value>)",
keyword: "rgb(var(--syn-keyword) / <alpha-value>)",
builtin: "rgb(var(--syn-builtin) / <alpha-value>)",
function: "rgb(var(--syn-function) / <alpha-value>)",
operator: "rgb(var(--syn-operator) / <alpha-value>)",
punctuation: "rgb(var(--syn-punctuation) / <alpha-value>)",
property: "rgb(var(--syn-property) / <alpha-value>)",
tag: "rgb(var(--syn-tag) / <alpha-value>)",
plain: "rgb(var(--syn-plain) / <alpha-value>)",
add: "rgb(var(--syn-add) / <alpha-value>)",
del: "rgb(var(--syn-del) / <alpha-value>)",
},
status: {
success: "rgb(var(--status-success) / <alpha-value>)",
danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <alpha-value>)",
},
},
backgroundColor: tinted,
borderColor: tinted,
fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
+192 -7
View File
@@ -145,7 +145,10 @@ path. Existing cwd values still pass through unchanged. `pipeline` is
optional and, like `title`, applies **only when a lane is actually created**
— an existing lane is returned untouched. Omitted, the lane gets `default`.
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
field. An id no template defines returns `400 EBADPIPELINE`.
field. `exact: true` makes only a lane on this very directory count: without
it a `cwd` nested inside another lane returns that parent lane (`created:
false`); with it the nested directory gets a lane of its own. The Add-lane
modal sends it, and says so when the directory already has a lane. An id no template defines returns `400 EBADPIPELINE`.
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
@@ -381,7 +384,11 @@ What the lane's directory looks like to git right now:
"files": [
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
]
],
"hasRemote": true,
"upstream": "origin/feat/rename-metric",
"ahead": 2,
"behind": 1
}
```
@@ -393,10 +400,179 @@ What the lane's directory looks like to git right now:
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
unstaged work together — and are `null` when git has no delta to report: an
untracked file, a binary file, a rename, or a repo with no commits yet.
- `hasRemote`, `upstream`, `ahead` and `behind` describe the branch's standing
against its remote-tracking ref. A branch that has never been pushed reports
`upstream: null` with `ahead`/`behind` **null** — not `0`, which would claim
"in sync" about a comparison that was never possible.
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
and the lane list is polled and re-broadcast on every hook. See
`docs/LANES.md` for the full treatment.
#### Read a lane's code changes
```http
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=<file>][&full=1][&context=N]
```
One route, three depths — the shapes the Workspace's Changes panel asks for:
| Query | Shape | Used by |
| --- | --- | --- |
| *(none)* | every changed file with its counts, no patch text | overview |
| `full=1` | the same list, each file carrying its `hunks` | review |
| `path=<file>` | that one file, with its `hunks` | single-file view |
| `context=N` | the same, with N unchanged lines around each hunk | the viewer's expand button |
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
untracked files included, `branch` is `<base>...HEAD` for the lane's
`base_branch`, and `commit` shows the single commit named by `sha` (an
unresolvable one is `400 EBADREV`, never an empty diff).
`context` defaults to **3** and is clamped to **5000** — at the top of that
range the diff has effectively become the whole file, which is what "expand
all" means in the panel.
```json
{
"available": true,
"range": "work",
"base": "development",
"baseRef": "origin/development",
"sha": null,
"context": 3,
"truncated": false,
"files": [
{
"path": "server/lib/git-ops.js",
"status": " M",
"untracked": false,
"staged": false,
"unstaged": true,
"conflicted": false,
"added": 12,
"deleted": 3,
"binary": false,
"hunks": [
{
"header": "@@ -1,2 +1,3 @@",
"context": "runGitAction",
"oldStart": 1,
"newStart": 1,
"lines": [{ "type": "add", "oldLine": null, "newLine": 2, "text": " const x = 1;" }]
}
]
}
]
}
```
- Each line is already resolved to both gutters (`oldLine`/`newLine`, one of
them `null` on an added or removed line), so a client renders rows without
re-parsing `@@` headers.
- `staged`, `unstaged` and `conflicted` come from the porcelain XY columns and
are reported for `range=work` only — they are what the panel's per-file
stage/unstage buttons key off.
- An **untracked** file has no git-side "before", so its patch is synthesized
as one all-added hunk from the file itself. A binary file reports
`binary: true` and `hunks: []`.
- Three caps guard the response: **300** files listed, **200 KB** of patch per
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
the response and on the file it cut.
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
git cannot read; `400 ENOBASE` when `range=branch` on a lane with no base
branch, and `400 ENOFILECHANGE` for a `path` with no changes in that range.
#### Read a lane's commits, a file, or the file tree
```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
GET /api/lanes/:id/git/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir>
```
The reads behind the Changes panel's **History**, whole-file view, quick-open
and file explorer. All four are read-only and unguarded, and all four answer
`{available:false}` with HTTP **200** for a cwd git cannot read.
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
commits touching that path. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
`rev=work` (default) is the file on disk — the one branch that reads the
filesystem, and it is realpath-confined to the lane directory; anything else
goes through `git show <rev>:<path>`. Binary content is reported, never
returned. Capped at 1 MB. `400 EBADPATH`, `EBADREV` or `ENOFILE`.
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
reads `git ls-files`, so ignored files never appear and no skip-list has to
be maintained; basename hits rank first.
- **`/git/tree`** → `{available, path, entries:[{name, path, dir}], truncated}`
— one directory level, tracked entries only, directories folded and listed
first. `400 EBADPATH` for a path that escapes the lane.
#### Read a lane's own branches
```http
GET /api/lanes/:id/git/branches
```
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
— the local branches of the lane's own repo, for the branch switcher. The repo
comes from the lane row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a lane that does
not exist yet).
#### Act on a lane's working copy
```http
POST /api/lanes/:id/git/:action
```
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
per lane under the lane lock.
| Action | Runs | Body |
| --- | --- | --- |
| `fetch` | `git fetch --all --prune` | — |
| `pull` | `git pull --ff-only` | — |
| `push` | `git push -u origin HEAD` | — |
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
```
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
running agent. There is deliberately no merge, rebase, reset or stash here —
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
`discard` accept only paths `git status` is reporting as changed right now,
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
- `discard` is the one irreversible action here and it **requires**
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice.
- `branch-create` never switches — the usual reason to create one from a lane
is to spawn a sibling worktree lane, and moving this pane's HEAD under a
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`); a git failure is
**500** with git's own `stderr` appended to `error.message` — a rejection or a
credential failure reads better in git's words.
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
remote fails instead of holding the request open.
#### Read a lane's runtime
```http
@@ -1648,6 +1824,7 @@ GET /api/cc-config/profiles
POST /api/cc-config/profiles Body: { name? }
PATCH /api/cc-config/profiles Body: { name, label }
POST /api/cc-config/profiles/activate Body: { name }
POST /api/cc-config/profiles/usage Body: { name }
DELETE /api/cc-config/profiles Body: { name }
PUT /api/cc-config/file Body: { scope, type, name?, content }
DELETE /api/cc-config/file Body: { scope, type, name? }
@@ -1693,10 +1870,18 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
`PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `<CLAUDE_HOME>/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. `keybindings.json` is replaced wholesale here because the live CLI does not rewrite it mid-session; `settings.json`, which it does, is only ever key-merged (see `PATCH /settings` above).
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity.
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file.
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path.
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against `.credentials.json` and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
**Nothing calls this on a timer**, but opening the Profiles modal does ask for every saved account at once — comparing accounts is the only reason the panel exists, and a button per row made the user do that by hand. What keeps that affordable is the pair of rules above: the live account costs a plain read, and any other profile is refreshed only when its token has actually expired, which leaves it valid for hours of further opens. Each row resolves on its own, so one superseded account still lets its neighbours report. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `ESTALE` (409 — the refresh token was already spent by a newer login or refresh; activate the account once and save the profile again), `ELIVEEXPIRED` (409), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`.
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
@@ -1758,7 +1943,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
`permissionMode` (`acceptEdits` default, or `default` / `plan` / `bypassPermissions`) is the mode the pane starts in. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it.
`permissionMode` (`auto` default, or `acceptEdits` / `default` / `plan` / `bypassPermissions`) is the mode the pane starts in — `auto` is the CLI's own classifier mode: it asks only for what it reads as risky. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it.
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
@@ -1790,14 +1975,14 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. Only the name comes from the request; what is written, and where, is derived from the lane row:
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. The response carries `restart_required` and `run_id`: a pane already running adopts the new connection but keeps the model it resolved at startup, so it must be restarted before the switch is real. Only the name comes from the request; what is written, and where, is derived from the lane row:
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together.
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. The updated lane is then broadcast as `lane_update`, same as every other mutating lane route — without it a client's cached row keeps the old `router_profile`, and re-selecting the lane re-reads that stale row and flips the picker back to the previous profile.
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
@@ -1981,7 +2166,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
+10 -4
View File
@@ -159,10 +159,16 @@ npm run install-hooks
> **Skip this entirely when the `agenthub` plugin is installed.** The plugin declares
> the same eight hooks itself (inline in `.claude-plugin/plugin.json`, each
> running `${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`). Running both means
> every event is POSTed twice — events carry no id, so ingest cannot deduplicate
> them and every token and cost figure doubles. `scripts/plugin-bootstrap.js`
> every event is POSTed twice. Events carry no id, so ingest can only drop a
> POST whose payload is byte-identical to the session's LATEST event from the last
> 10 seconds (`findRecentDuplicateEvent`, since v1.8.1) — that stops the doubled
> feed rows, token/cost figures and subagent cards, but the second hook process
> still runs on every event, so remove the duplicate registration anyway. `scripts/plugin-bootstrap.js`
> removes checkout-installed entries on session start (backup:
> `~/.claude/settings.json.agenthub-bak`), `install-hooks.js` warns when it detects
> `~/.claude/settings.json.agenthub-bak`) — and since v1.6.17 a settings file that
> still carries them also blocks the bootstrap's fast path (`hasLegacyHooks`),
> because the strip runs only in the full pass and a healthy install skipped it
> every session, keeping the duplicates alive indefinitely. `install-hooks.js` warns when it detects
> a plugin install, and `/agenthub-doctor` reports any duplicates that remain. See
> [PLUGINS.md](PLUGINS.md).
@@ -422,7 +428,7 @@ Triggered when Claude Code sends a system notification.
- Log the event for the activity feed
- If the message matches a permission/input-prompt pattern (`permission`, `waiting for input`, `needs your approval`, `awaiting your response`, …), stamp `awaiting_input_since` (with `awaiting_reason` = `notification`) so the session lands in **Waiting**
- If the message matches a compaction pattern, tag as a `Compaction` event
- Trigger a browser notification when the user has notifications enabled
- Trigger a browser notification when the user has notifications enabled — this event and `Stop` are the two "Claude is waiting for YOU" moments, so each has its own switch in Settings → Notifications (`onAwaitingInput` / `onTurnComplete` in `client/src/lib/notifPrefs.ts`) and both default to ON once notifications are enabled
---
+219 -27
View File
@@ -41,11 +41,53 @@ Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-eff
The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`).
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane keeps the endpoint it started with — Claude Code reads `settings.local.json` at startup — so the change takes effect on the next run. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
In Worktree mode the branch question has three answers, because git's own rule
decides which is available (issue #34):
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
- **New branch** — pick the base to fork from, and optionally a name. Leaving
the name empty is the normal case: the route derives `${LANE_BRANCH_PREFIX}${slug}`
exactly as the CLI does, and the field's placeholder previews that name as you
type the title. The modal no longer demands one.
- **Existing branch** — check a branch that already exists out into the new
worktree, with no base and no new branch. Git allows one worktree per branch,
so any branch another worktree already holds is listed but **disabled**, named
with the directory holding it; picking it would have failed provisioning with
`EBRANCHBUSY` after the lane row already existed.
- **Current branch** — no branch at all: the lane simply *is* the repo, working
on whatever is checked out (`main`). Small and personal projects usually want
this and nothing more, so the choice sits beside the other two rather than
being reachable only from the top-level mode toggle — it switches the modal
to **Repo** mode, which is exactly that behaviour. A worktree lane could not
offer it in any case: git will not check `main` out in a second worktree
while the repo itself holds it.
The modal also accepts a **prefill** (repo + branch). That is what the lane git
bar's **New lane here** opens — a sibling worktree of the same repo on the
branch selected there, created through this same flow rather than a second
provisioning path.
Above the branch fields, once the repo path resolves, the modal lists **that
repo's existing worktrees** (branch + directory, first eight). `GET
/api/lanes/branches?repo=` carries them alongside the branch list — one
subprocess more on a form still being typed into, versus a second round trip
for facts that expire together.
The repo field's autocomplete also offers a **Source repos** group: the
`source_repo` of every lane already provisioned, newest first, above the
session-history recents (`GET /api/run/cwds`, `kind: "repo"`). A worktree lane's
recent cwd is the *worktree*, never the repo it was cut from, so the one path
this field wants had to be retyped every time.
The modal has **no pipeline picker**: every lane it creates is born on
`default` (the server's own default), and the template is changed afterwards
from the lane's own detail header in Workspace, or at creation from the
terminal with `agenthub lanes add --pipeline <id>`. *Model endpoint* is the one
per-lane extra the form still asks for, and it sits in the form itself rather
than behind a disclosure — a disclosure around a single field only hid it.
The **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
@@ -54,7 +96,7 @@ Both modes' path field has a **Browse** button next to it, opening a small folde
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane's tool determines which CLI is spawned when a run starts.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude lanes render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but no Sessions/Analytics/cost — and the console never pre-fills the pipeline's Claude slash command into a codex pane.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
### How Codex arguments map
@@ -118,18 +160,18 @@ agenthub lanes remove <id> [--force] --yes
agenthub lanes purge <id> --yes
```
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated lane's reset/remove will drop — see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` IS allowed for an adopted lane: it drops only the dashboard's record and leaves the directory, its contents, and its database (if any) untouched.
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated lane's **reset** will drop — `remove` drops no database at all; see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits — it applies to `reset` alone, since `remove` deletes nothing that could hold them. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` is allowed for **every** lane kind, adopted or managed, because it drops only the dashboard's record and leaves the working tree, its branch, and its database (if any) untouched — deleting those is yours to do by hand.
## Lifecycle
### The two lane kinds
- **`managed`** — a git worktree the dashboard itself created (`agenthub lanes add --repo`), rooted under `LANES_ROOT`. The dashboard owns its full lifecycle: it may reset the worktree to its base branch or remove it entirely.
- **`managed`** — a git worktree the dashboard itself created (`agenthub lanes add --repo`), rooted under `LANES_ROOT`. The dashboard may reset that worktree to its base branch. It does NOT delete it: removing the lane forgets the dashboard's record and leaves the directory and its branch on disk.
- **`adopted`** — an existing directory you already had (`agenthub lanes add --cwd`). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted lane drops the dashboard's record of it and leaves the directory untouched.
### The three independent safety checks
Before `reset` or `remove` touches a worktree, `assertDestroyable` (`server/lib/worktree.js`) runs three checks, all of which must pass:
Before `reset` touches a worktree, `assertDestroyable` (`server/lib/worktree.js`) runs three checks, all of which must pass:
1. **`lane.kind === "managed"`.** An adopted lane fails immediately with `ENOTMANAGED` — this check alone is what keeps a directory you own off the destroy path.
2. **The lane's `cwd`, resolved with `fs.realpathSync`, must sit inside the resolved `LANES_ROOT` on a path boundary.** `path.relative(root, cwd)` must not start with `..` or be absolute — `EOUTSIDEROOT` otherwise. This guards against a lane row that was hand-edited or drifted to point somewhere else.
@@ -137,14 +179,14 @@ Before `reset` or `remove` touches a worktree, `assertDestroyable` (`server/lib/
These checks run unconditionally inside `resetWorktree`/`removeWorktree` themselves, not only when a confirmation is present — there is no code path to a git-destructive operation that skips them.
One documented exception, which does not weaken them: when a managed lane's directory has already been deleted by hand, `removeWorktree` takes a **prune path** instead. Checks 1 and 2 still hold (an adopted lane is refused outright; the recorded `cwd` must still be inside `LANES_ROOT`, verified lexically because a vanished path cannot be resolved), and check 3 is what the prune replaces — a worktree git no longer lists needs no removal. That path runs `git worktree prune` plus a safe branch delete against the source repo's bookkeeping and touches no directory at all.
`removeWorktree` (with its prune path for a hand-deleted directory) still lives in `server/lib/worktree.js` and still runs those checks, but **no route calls it any more**: the lane `remove` action no longer destroys anything. It is kept as a guarded primitive, not a reachable lane verb.
### Each verb
- **`add`** — either adopts an existing directory (`--cwd`, kind `adopted`) or provisions a new managed worktree (`--repo`, kind `managed`, created under `LANES_ROOT` on branch `feat/<slug>`).
- **`clear`** (UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted.
- **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
- **`remove`** — for a `managed` lane whose directory exists: `git worktree remove --force`, `git worktree prune`, then a safe branch delete, then the lane row is deleted. For a `managed` lane whose directory was deleted by hand: the prune path above (git's stale record and the branch are cleaned up, then the row is deleted) — a `missing` worktree does NOT make a lane unremovable. For an `adopted` lane: only the lane row is deleted (`removeWorktree` is never invoked for `adopted`, so the directory is never touched). The unpushed-commits force-gate only applies when the worktree is actually touched, i.e. `action === "remove" && kind === "managed"` — forgetting an adopted lane needs no `force` even with unpushed commits, since nothing on disk is at risk.
- **`remove`** — **deletes the dashboard's record of the lane and nothing else**, for every lane kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the lane's database (if any) is left in place; deleting any of them is the user's own call (`git worktree remove`, `git branch -d`, dropping the database). Stopping the lane's run and stack still happens first — those are processes, not data — and the runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/` (pid files, hook logs) is cleared, because it is AgentHub's own and would otherwise be inherited by the next lane to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit.
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories. No git operation, no worktree change; not gated on unpushed commits.
### The preflight contract
@@ -156,11 +198,11 @@ One documented exception, which does not weaken them: when a managed lane's dire
`blocked[]` can contain: `adopted` (not a managed worktree), `missing` (the directory is gone), `unreadable` (directory exists but git failed against it), and `unpushed-commits` (unpushed count > 0 — the only one a `force: true` can override).
**Which entries actually prevent the action depends on the action, and the server is the authority.** All of them block `reset`. **None of them blocks `remove`** — the server supports removing an adopted lane (forget the row), a hand-deleted worktree (prune path), and an unreadable one: it attempts `git worktree remove --force`, and if git itself refuses (e.g. a corrupt worktree `.git` pointer, which git validates and rejects even with a second `--force`), it deregisters the worktree directly from the source repo's bookkeeping instead — the directory itself is never touched either way. A surface that greys out confirmation on any `blocked` entry regardless of action makes a documented capability unreachable; `client/src/components/lanes/DestructiveLaneModal.tsx` encodes the per-action set in `HARD_BLOCKERS` and renders the rest as context.
**Which entries actually prevent the action depends on the action, and the server is the authority.** All of them block `reset`. **None of them blocks `remove`**, including `unpushed-commits`: removal only deletes the lane's row, so an adopted lane, a hand-deleted directory and an unreadable one are all removable and none of them risks anything on disk. For `remove` the preflight counts are context, not a destruction manifest. A surface that greys out confirmation on any `blocked` entry regardless of action makes a documented capability unreachable; `client/src/components/lanes/DestructiveLaneModal.tsx` encodes the per-action set in `HARD_BLOCKERS` and renders the rest as context.
`unpushed` is the count of commits the action would really discard: with remotes, commits on no remote; without remotes, the commits ahead of the lane's `base_branch`, which is this lane's own work. A freshly provisioned worktree in a local-only repository reports `0`, not that repository's whole history — the `no-remote` warning, not an inflated count, is what tells the user nothing is backed up. A lane with no `base_branch` at all (an adopted one) falls back to the total commit count. `warnings[]` holds purely informational facts that never gate the action: currently just `no-remote` (no git remote configured at all — nothing here is backed up remotely, but the action proceeds normally). Both arrays are data, not an exception.
`database` is the database name a `reset` (unless `--keep-db`) or `remove` will drop — `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are.
`database` is the database name a `reset` (unless `--keep-db`) will drop — `remove` never drops one — `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are.
`bytesEstimate` is a **rough estimate**, not a measured disk size: it is derived as `(events + tokenRows) * 512`, a flat per-row byte guess, purely to give the confirmation dialog an order-of-magnitude sense of what purging will reclaim.
@@ -362,7 +404,7 @@ Parsed with the same literal `KEY=VALUE` reader as `profile.env` — never sourc
- **Provisioning** a new managed worktree (`agenthub lanes add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once.
- **`up`** — repair `.env` (a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does), `migrate` on every boot (a lane's schema drifts while it sits idle), and `seed` only on the boot that actually created the database.
- **`reset`** — refresh `.env` with `--force` (preserving `ENV_PRESERVE` keys from the lane's own file — swapping in the source's `JWT_SECRET` would 401 a running lane's tokens until reboot), re-run `bootstrap` (a reset can land on a branch with new dependencies), clear the declared `LANE_DIRS`, then drop, recreate, migrate and reseed the database — unless `--keep-db`, which skips that whole block.
- **`remove`** — drops the database and its `_test` sibling before the rest of teardown. Best-effort: a failed drop is logged, never blocks removing the lane's record. **Never runs for an adopted lane** — its data was never AgentHub's to create, so it is never AgentHub's to destroy, the same invariant that protects an adopted lane's worktree.
- **`remove`** — drops **nothing**. `removeLaneData` still exists in `server/lib/lane-runtime.js` (and still refuses an adopted lane), but the `remove` action no longer calls it: a lane's database is the user's data and outlives the dashboard's record of it. Drop it yourself when you actually want it gone.
A missing source `.env` falls back to `.env.example` with a loud warning, never a silent success. And any hook output that echoes a value from `secrets.env` (the password, specifically) is redacted before it reaches `$LOG_DIR/<hook>.log` or the `lane_hook_output` WebSocket message — that stream reaches a browser tab, and a hook debugging its own environment must not publish a database password to everyone watching.
@@ -372,11 +414,13 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add lane** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-lane modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which.
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
@@ -386,7 +430,7 @@ Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
**Picking a session to resume starts it right away.** In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — `POST /api/lanes/:id/start` with that `resumeSessionId` and the session's own `cwd` — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. Type into the tmux terminal once it's attached.
**Picking a session to resume starts it right away.** In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — `POST /api/lanes/:id/start` with that `resumeSessionId` and the session's own `cwd` — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. A resume also **sends no prompt at all** — whatever is left in that box is dropped rather than fired as an extra turn into the session being continued. Type into the tmux terminal once it's attached.
### Active runs list
@@ -400,8 +444,12 @@ An external session is deduped against a dashboard run with the same `session_id
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
Live, dashboard-spawned rows (not external — there is no tmux session to kill for those) also carry a **Kill** button, `DELETE /api/run/:id` behind a two-click confirm. This is how you free a lane stuck on a previous live session when you actually want it gone, rather than attached to.
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
**A lane whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The console does not surface that raw error: `LaneConsolePane` catches it, re-reads the lane, and attaches to the run it is already holding instead — with a notice explaining that the previous session is still live, so nothing new started. Killing that session (Active runs list → **Kill**, two clicks, the same confirm convention as discarding a file in the Changes panel) is what actually frees the lane for a fresh Start or a different Resume; the kill goes straight through `DELETE /api/run/:id`, and the lane's own `run_id`/`status` self-heal to idle the next time anything reads the lane (`healRunId` in `routes/lanes.js`), so no separate lane-side call is needed.
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
### Finding or adopting a lane: `POST /api/lanes/ensure`
@@ -424,7 +472,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
```
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
- A relative or missing `cwd` returns `400 EBADCWD`.
@@ -484,6 +532,124 @@ five times, and the lane list is polled and re-broadcast on every hook-driven
tool call an agent makes. The browser fetches it per card instead, on mount and
every 30 seconds, and a failed fetch is silent.
The same response also carries the branch's standing against its remote:
`hasRemote`, `upstream`, `ahead`, `behind`. A branch that has never been pushed
reports `upstream: null` and **null** counts — not zeros, which would claim "in
sync" about a comparison that was never possible. The card renders them as
`↑2 ↓1` beside the git actions below.
### Acting on a lane's working copy: the git action row
Under the git line, a lane's card carries the git **writes** the dashboard
offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`):
| Button | Runs | Refuses when |
| --- | --- | --- |
| **Fetch** | `git fetch --all --prune` | the repo has no remote |
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
| **Push** | `git push -u origin HEAD` | the remote rejects it |
| **Commit** | `git add -A` + `git commit -m …`, or **Commit staged** (the index as it stands) | nothing is uncommitted / nothing is staged |
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
| **Create** | `git branch <name>` — never switches | the branch already exists |
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
These sit on the card rather than in the ⋯ menu deliberately: that menu holds
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
lane needs several times an hour. The boring half of git is the whole list on
purpose — there is no merge, rebase, reset or stash button, because a dashboard
click is the wrong place to start an operation whose recovery needs a terminal.
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
inventing a merge commit or a conflict underneath a running agent. **Create**
deliberately does not switch: the usual reason to make a branch from a lane is
to spawn a sibling worktree lane on it, and moving this pane's HEAD under a
working agent is not. **New lane here** goes through the normal Add-lane flow
with the fields prefilled — one provisioning path for every lane, not a second
one hidden in the git bar.
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
says more than any sentence the dashboard could write.
### The Changes panel
To the right of the lane detail sits a collapsible **Changes** card (its state
persists per viewer in `localStorage`). Its header holds **one** control, which
steps narrow → 50/50 → 70/30 → closed (`agenthub.workspace.changesSize`
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
narrow. Reading a diff and watching a console want opposite amounts of room and
which one is "work" changes several times an hour, so the width is a step, not
a setting — and one button beats a width button wedged beside a collapse button
in the panel's tightest corner. It is icon-only, and the icon it draws is the
width the next click LANDS on (even split → wider → closed), with the wording
left to its tooltip. It
reads the lane's git surface in five modes (**Files** sits first: it is the
only one that answers "show me the code" rather than "show me the change"):
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
(`/git/tree`), which expands folders in place rather than navigating into
them — a folder's children render nested under it, siblings stay visible,
and a toolbar button expands or collapses every folder at once (each
unfetched level lazily calls `/git/tree` the first time it's opened).
Opening a file entry shows the whole file (`/git/file`).
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons.
- **Review** — every file's full diff, stacked, for reading a whole change-set.
Clicking a file's header folds it shut; only clicking the **path itself**
opens that file. (A header that navigated on any click made folding a
target-practice exercise against one chevron.) One toolbar button folds or
unfolds all of them. What is remembered is the set of *collapsed* paths, so
a file that appears later is open by default; a lane switch clears it.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
and landing back at its top is the whole cost of opening one file.
- **History** — the lane's recent commits (`GET /:id/git/log`); 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 **vs base**
(`<base>...HEAD` — what this lane's branch adds on top of `base_branch`; a lane
with no base says so rather than guessing one). Opening a commit from History
adds a third range, shown as a short-sha chip.
The viewer itself (issue #35) is VS Code-shaped: a **unified / side-by-side**
toggle, syntax highlighting from the tokenizer the conversation view already
ships (`client/src/lib/highlight.ts` — no new dependency), the changed span
inside a modified line picked out by a common prefix/suffix trim, **expand /
collapse** buttons that walk `context` both ways along 3 → 12 → 40 → the whole
file, and a **whole file** switch that reads the file itself instead of its
diff.
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
the bottom of a 2000-line container is only reachable after scrolling to the
end of the file — the one place nobody needs it.
Side-by-side is two *independently scrolling* columns whose horizontal scroll
is mirrored, not one wide row cut in half: a long line has to be able to run
past its column's edge, and halves inside a single scroller instead painted
over each other with nothing to scroll. The toolbar is two rows for the same
reason — *where you are* (mode, refresh) above, *how you are looking at it*
(range, layout, context, whole file) below — because one row wrapped mid-group
in every panel narrow enough to sit beside a console.
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
one file means nothing unless a commit can take the index as it stands, so the
overview grows a commit box the moment anything is staged — it sends
`only: "staged"`, never `add -A`, and it is absent with a clean index because
there is nothing it could mean. (The git bar's own **Commit staged** is the
same call from the lane card; its plain **Commit** is still stage-everything.) **Discard** is the one
irreversible action in the panel: it asks twice in the UI, and the server
refuses it without `confirm: true` and accepts only paths `git status` is
already reporting as changed.
Review, single-file, history and browse do **not** poll: re-fetching under the
reader's scroll position is worse than a slightly stale view, and those are the
expensive shapes. Diffs are capped server-side (300 files, 200 KB per file,
1 MB per full read) and say so when a cap bit.
## Viewing lanes
List all lanes with their current status:
@@ -511,7 +677,7 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal has a *Pipeline template* select listing every template the server reports, so this is chosen at creation; `agenthub lanes add --pipeline <id>` is the same choice from the terminal.
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal does not ask — it always creates on `default`. Pick another with `agenthub lanes add --pipeline <id>` at creation, or change it afterwards from the *Pipeline template* select in the lane's detail header in Workspace.
For a lane that already exists, switch it from the terminal:
@@ -556,6 +722,7 @@ The lane is resolved from the working directory, so no id is needed (pass one to
agenthub lanes node # list every node of this lane's pipeline and its command
agenthub lanes node intake # → claude "/superpowers:brainstorming"
agenthub lanes node intake the export button # extra words are appended to the prompt
agenthub lanes node review # → claude "/code-review"
agenthub lanes node gates # → agenthub lanes hook ci-gate
agenthub lanes node pr-open --print # show the argv, run nothing
```
@@ -1070,11 +1237,12 @@ command: it's whichever of the fields above the matching rule's `tool` carried.
### Superpowers skill invocations
The built-in `default` pipeline's `plan`, `implement`, `review`, and `ship`
nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
Superpowers workflow skill names (`brainstorming`/`writing-plans`,
`executing-plans`/`subagent-driven-development`, `code-review`/
`requesting-code-review`, `finishing-a-development-branch`). Invoking one of
The built-in `default` pipeline's `intake`, `plan`, `implement`, `review`, and
`ship` nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
workflow skill names they expect (`brainstorming`, `writing-plans`,
`executing-plans`/`subagent-driven-development`, Claude Code's own
`code-review` plus `requesting-code-review`/`receiving-code-review`,
`finishing-a-development-branch`). Invoking one of
these skills is a much stronger signal than a matched Bash command, but it is
still detection, not declaration — it renders dashed amber and never `done`,
same as every other detected stage.
@@ -1123,8 +1291,8 @@ a `detect` array on each node. The built-in `default.json`
| Node | Detect rules |
|---|---|
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle |
| `plan` | `Skill` matching `brainstorming\|writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle; `Skill` matching `brainstorming`, which is intake's own declared command |
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
| `implement` | `Skill` matching `executing-plans\|subagent-driven-development\|test-driven-development`; `Edit` (any); `Write` to any path NOT under a `docs/` directory (`^(?!.*(?:^\|/)docs/)`) |
| `tests` | `Bash` matching `\b(npm (run )?test\|pytest\|vitest\|jest\|go test\|cargo test\|node\s+--test\|rspec\|phpunit\|dotnet test\|mvn test\|gradle test)\b`; `Agent` matching `Test Results Analyzer\|API Tester\|Performance Benchmarker` |
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Bash` matching `\bgh\b[^;&\|]*\bpr diff\b`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind), `Evidence Collector`, or `Accessibility Auditor` |
@@ -1299,7 +1467,9 @@ curl -X POST http://localhost:4820/api/run \
The `initialPrompt` field is optional — if omitted, the tmux session is created/attached with no initial input, and you type into the terminal directly. Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The same tmux session persists across attach/detach cycles, so you can switch between the browser Workspace and `agenthub lanes shell` seamlessly.
**Permission mode in a lane run.** `permissionMode` (one of `acceptEdits` — the default — `default`, `plan`, `bypassPermissions`) sets the mode the pane *starts* in. The pane's `claude` also gets `--allow-dangerously-skip-permissions`, which puts **bypass permissions** into the pane's own `shift+tab` cycle **without enabling it** — matching what you get in a real terminal, where the cycle would otherwise be limited to plan / auto / manual / accept-edits and bypass would be unreachable from inside a dashboard-started run. Bypass is still only ever entered by an explicit `shift+tab` in the pane or by asking for `permissionMode: "bypassPermissions"` up front; nothing skips permission checks by default.
**The model picker offers aliases, not pinned ids.** The Run setup's Model select sends `fable` / `opus` / `sonnet` / `haiku` (plus `Custom…` for a literal id, and the empty value that inherits `settings.json`). `claude --model` documents those as *"an alias for the latest model"* and resolves each itself — 2.1.278 maps `fable` to `claude-fable-5-1` — so a new model release reaches the dashboard the moment the CLI is updated, with no list to edit by hand. Cost maths is unaffected: pricing keys off the model id the transcript reports, which is always the resolved full name. Codex has no such aliases, so `RUN_MODEL_CHOICES_CODEX` stays a pinned list.
**Permission mode in a lane run.** `permissionMode` (one of `auto` — the default — `acceptEdits`, `default`, `plan`, `bypassPermissions`) sets the mode the pane *starts* in. `auto` hands the decision to the CLI's own classifier, which asks only for what it reads as risky; it is what a terminal `claude` starts in too. The pane's `claude` also gets `--allow-dangerously-skip-permissions`, which puts **bypass permissions** into the pane's own `shift+tab` cycle **without enabling it** — matching what you get in a real terminal, where the cycle would otherwise be limited to plan / auto / manual / accept-edits and bypass would be unreachable from inside a dashboard-started run. Bypass is still only ever entered by an explicit `shift+tab` in the pane or by asking for `permissionMode: "bypassPermissions"` up front; nothing skips permission checks by default.
**A killed run releases the lane.** When the tmux session is terminated (from the terminal, browser stop button, or external `kill`), the lane's `run_id` is cleared, its status returns to `idle`, and the change is broadcast as `lane_update`. Runs are recorded with the lane's id and are listable via `GET /api/run/history?laneId=<n>`.
@@ -1311,6 +1481,28 @@ Terminate the running session. If the lane has no live run, this is a no-op (no
curl -X POST http://localhost:4820/api/lanes/5/stop
```
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
The git writes the lane card's action row and the Changes panel offer. See
"Acting on a lane's working copy" above for what each one refuses and why.
```bash
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "wip: from the dashboard"}'
# Only what the Changes panel staged
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "wip: staged only", "only": "staged"}'
# Irreversible, so `confirm` is required and the path must be one git
# already reports as changed
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
-H "Content-Type: application/json" \
-d '{"paths": ["src/scratch.ts"], "confirm": true}'
```
### clear
Reset the lane's pipeline progress, stages, notes, and inferred stage. The lane itself persists; only its recorded history is cleared.
@@ -1321,7 +1513,7 @@ curl -X POST http://localhost:4820/api/lanes/5/clear
### remove
Delete the lane entirely, including all its history. **Requires confirmation** (`confirm: true`):
Delete the dashboard's record of the lane, including its recorded history. The working tree, its branch and its database are left untouched. **Requires confirmation** (`confirm: true`):
```bash
curl -X POST http://localhost:4820/api/lanes/5/remove \
@@ -1329,7 +1521,7 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \
-d '{"confirm": true}'
```
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidental deletion.
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a lane's record. It deletes that record only — never the worktree, the branch or the database.
## Proof gallery
+6 -4
View File
@@ -212,8 +212,8 @@ any of them is ever deleted:
| The directory `agenthub.service` names (`unitRoot`, **foreign unit included**) | A service is executing from it. Not ours to rewrite is still not ours to delete. |
| Any directory named in the user's `settings.json` hooks | A Claude Code process that has not been restarted is still executing from there — this is the same directory the section above describes, seen from the other side. Deleting it would break that session's hooks mid-flight. |
Plus the newest `KEEP_RETIRED_ROOTS` (2) of whatever is left, as a rollback
cushion. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for
Plus the newest `KEEP_RETIRED_ROOTS` (1) of whatever is left, as a rollback
cushion — one build deep, because a full copy of this repo is ~130 MB. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for
a git checkout the parent directory holds the user's own work. Best effort
throughout: a directory that will not delete is left alone, never an error.
@@ -312,8 +312,10 @@ string a template names — as the pipeline's own `skill` or as one node's
`command` — has to come from an installed, enabled plugin. Today that means
**`superpowers`**, which `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`
drive step by step (`brainstorming`, `writing-plans`,
`test-driven-development`, `requesting-code-review`,
`verification-before-completion`, `finishing-a-development-branch`). Install it
`test-driven-development`, `verification-before-completion`,
`finishing-a-development-branch`). The `review` node is the exception: it names
the bare `/code-review`, Claude Code's own review skill, which carries no
`plugin:` prefix and so requires nothing to be installed. Install it
with `claude plugin install superpowers@claude-plugins-official`.
The row is a **WARN, never a FAIL**: the dashboard, hooks and CLI all work
@@ -0,0 +1,63 @@
# Appearance: mode + named themes, and readable diff rows
## Requirement (from the user, 2026-09-23)
- `/settings` gets an **Appearance** section: mode **Dark / Light / System**, plus a named theme per mode.
- Themes: dark = **Default** (current Radix slate), **Darcula**, **Apple Dark**; light = **Default** (current ivory), **Apple Light**, **IntelliJ Light**, **Canvas** (linen).
- A theme also recolours code syntax (`--syn-*`).
- The sidebar's dark/light control stays; each mode remembers its own theme (dark=Darcula, light=Canvas → toggling swaps between those two).
- The diff's +/- rows in Code changes (`LaneDiffView`) get visibly more contrast, in every theme.
## Approach
Themes are pure CSS-variable overrides — every colour in the app already reads `--surface-*`, `--fg-*`, `--accent*`, `--status-*`, `--tint-base`, `--syn-*`. No component learns theme names.
1. **`client/src/index.css`**
- Light default block becomes `:root, [data-mode="light"]` (so a light preview can be scoped inside a dark page).
- After the `.dark` block, one block per theme: dark ones `.dark[data-theme="<id>"]`, light ones `[data-theme="<id>"]` (placed after the defaults, same specificity as `[data-mode=light]`, later wins). Each overrides only what differs; `--tint-base` always = its `--surface-3`.
- New `--diff-add` / `--diff-del` NOT needed: rows use existing `status-*` tokens.
2. **`client/src/hooks/useTheme.ts`** → module-level store read via `useSyncExternalStore`, so Settings and Sidebar never disagree.
- Storage: `theme` key now holds `dark|light|system` (old values stay valid → no migration); `agenthub.theme.dark` / `agenthub.theme.light` hold theme ids; unknown id ⇒ `default`.
- `system` resolves via `matchMedia("(prefers-color-scheme: dark)")` and follows its `change` event.
- Applies `.dark` class + `data-theme` attribute on `<html>` (attribute removed for `default`).
- Keeps today's API (`theme` = resolved `dark|light`, `setTheme`, `toggleTheme` — toggle sets the explicit opposite mode) and adds `mode`, `setMode`, `themeFor`, `setThemeFor(mode, id)`, plus exported `THEMES` registry `{ dark: [...ids], light: [...ids] }`.
3. **`client/src/pages/Settings.tsx`** — new first section `appearance` in `SETTINGS_SECTIONS`: a 3-button mode segment, then two grids (Dark themes / Light themes) of cards. Each card's swatch is a `<div data-mode|class=dark data-theme=id>` painting `bg-surface-0`, a `bg-surface-3` card, an accent dot and two syntax colours — the CSS vars re-scope inside it, so previews need zero duplicated hex. Selected card = accent ring + `aria-pressed`.
4. **`client/src/components/Sidebar.tsx`** — expanded panel's theme grid gains **System** (3 buttons, Monitor icon); collapsed toggle unchanged.
5. **`client/src/components/lanes/LaneDiffView.tsx`** `rowTint` → `bg-status-*/20` + a 3px inset left bar in the status colour; the `+`/`-` marker coloured `text-status-*` and bold. `highlight.ts`'s `add`/`del` classes use the same `/20` status tokens instead of fixed `emerald/red-500/10`.
6. **i18n** `en`/`vi`: `nav.themeNames.system`, `settings.appearance.*` (title, mode, darkThemes, lightThemes, theme names).
## Revisions after the plan challenge
1. Store keeps NO cached module state: `getSnapshot` reads localStorage + `matchMedia` and returns a primitive string; `subscribe` listens to a custom `agenthub-theme` event, `storage` (cross-tab) and the media-query `change`. No DOM work at import. DOM applied synchronously in the setter and in a `useLayoutEffect` (no one-frame flash).
2. `window.matchMedia?.(...)` guarded (jsdom lacks it); the system test installs its own listener-capable mock.
3. `<html>` carries `data-mode="dark|light"` too; light themes scoped `[data-mode="light"][data-theme=x]`, dark `.dark[data-theme=x]` — no source-order dependency.
4. `--tint-base: var(--surface-3)` and `--syn-plain: var(--fg-primary)` declared once in each default block; theme blocks never set them.
5. Diff emphasis `LaneDiffView.tsx:48` `bg-white/15` → `bg-fg-primary/15` (invisible on light otherwise).
6. Swatch root sets `text-fg-primary` + `bg-surface-0`.
7. Palettes pinned below; contrast checked with a small node script (white-on-accent, fg-muted on surface-3, status on its /20 chip) — dark themes held to the SAME bar the current dark default meets (its #0090ff is 3.3:1 under white text), light themes to ≥4.5:1.
8. Appearance section lives in its own component `client/src/components/settings/AppearanceSettings.tsx`; Settings.tsx gains only the section entry + render.
9. Sidebar: check the expanded panel width with 3 theme buttons; collapsed toggle moves System → the explicit opposite mode (documented).
## Palettes (hex; surface-0..5, border, border-light, accent, accent-hover, fg 1/2/muted, success/danger/warning; syntax)
- **darcula**: #2b2b2b #303234 #36383a #3c3f41 #45494a #4e5254 · #4b4f51 #5e6366 · #3592c4 #4aa3d6 · #dcdcdc #a9b7c6 #8c9296 · #6aab73 #ff6b68 #e0b25c · keyword #cc7832 string #6a8759 number #6897bb comment #808080 function #ffc66d property #9876aa tag #e8bf6a builtin #8888c6 operator/punct #a9b7c6 add #6aab73 del #ff6b68
- **apple-dark**: #161618 #1c1c1e #242426 #2c2c2e #363638 #3a3a3c · #38383a #48484a · #0a84ff #409cff · #f5f5f7 #aeaeb2 #8e8e93 · #30d158 #ff6961 #ffd60a · keyword #fc5fa3 string #fc6a5d number #d0bf69 comment #7f8c98 function #67b7a4 builtin #5dd8ff property #a167e6 tag #fc5fa3 operator/punct #a1a1a6 add #30d158 del #ff6961
- **apple-light**: #e9e9ee #efeff4 #f5f5f8 #fbfbfd #fdfdfe #ffffff · #d1d1d6 #e5e5ea · #0066cc #0055b3 · #1d1d1f #515154 #6e6e73 · #1f7a36 #c5000f #8a5a00 · keyword #9b2393 string #c41a16 number #1c00cf comment #5d6c79 function #326d74 builtin #0b4f79 property #3900a0 tag #9b2393 operator/punct #4b4b50 add #1f7a36 del #c5000f
- **intellij-light**: #eceef2 #f1f2f5 #f7f8fa #fdfdfe #ffffff #ffffff · #d3d5db #e4e6eb · #3574f0 #2e63cf · #1e1f22 #494b57 #6c707e · #1d7a35 #c01f2a #8f5f00 · keyword #0033b3 string #067d17 number #1750eb comment #707070 function #00627a builtin #000080 property #871094 tag #0033b3 operator/punct #4b4b4b add #067d17 del #c01f2a
- **canvas**: #e3dccd #e8e2d4 #ede8dc #f3efe6 #f7f4ed #faf8f3 · #cbbfa6 #dcd3c0 · #7a5230 #663f22 · #2b2620 #5e554a #6b6155 · #3f6a2a #9f2f23 #7d5700 · keyword #8a3b12 string #4d6b1f number #9a5b00 comment #7d7262 function #7a5a00 builtin #2f5e6e property #3b6660 tag #8e2f2f operator #8a3050 punct #6b6155 add #4d6b1f del #9f2f23
(Values that fail the contrast script are darkened/lightened until they pass; the final CSS is the source of truth.)
## Out of scope
- Server-side persistence (theme stays a per-browser preference, like lane order).
- ~~A no-flash inline boot script~~ — added after acceptance at the user's request (`client/index.html`).
- User-defined/custom themes.
- Surfaces that already bypass the CSS variables (they clash with today's light default too): the xterm terminal (no `theme`, TerminalView.tsx), Dashboard/workflow chart hardcoded hex (Dashboard.tsx, OrchestrationDAG.tsx, ErrorPropagationMap.tsx), ~415 raw Tailwind palette classes (toolStyle.ts, types.ts, …), `index.html`'s `text-gray-200` body.
## Tests
- `hooks/__tests__/useTheme.test.ts`: existing 4 stay green; add — `system` follows a mocked `matchMedia` and its change event; `setThemeFor` sets `data-theme` only for the active mode and persists; unknown stored id ⇒ default (no attribute); two hook instances stay in sync.
- Settings: a test that clicking a theme card sets `data-theme` / clicking System sets mode.
- Snapshot diff for Settings/Sidebar reviewed, then regenerated deliberately.
- `npm run test:client`, `tsc` via build, `check-headers.sh`. Manual: build + open `/settings`, screenshot each theme.
## Acceptance
- [ ] Mode Dark/Light/System works and persists across reload; System follows the OS live.
- [ ] Each of the 7 themes selectable; per-mode memory; sidebar toggle swaps between the two remembered themes.
- [ ] Code syntax recolours per theme.
- [ ] Diff +/- rows clearly distinguishable in every theme.
- [ ] Docs (client README / CLAUDE.md marker) updated.
+367 -13
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.6.9
version: 1.8.4
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -13,7 +13,7 @@ externalDocs:
description: Project documentation
url: https://git.smartgift.io.vn/Smartgift-AI/AgentHub#readme
servers:
- url: http://localhost:4820
- url: http://localhost:4830
description: Local dashboard server (default)
- url: http://127.0.0.1:4820
description: Local loopback endpoint used by hook-handler
@@ -3026,6 +3026,21 @@ components:
example:
- /Users/son/.claude/.credentials.json
- /Users/son/.claude.json
CcConfigUsageWindow:
type: object
nullable: true
description: One quota window as the account reports it.
properties:
utilization:
type: number
nullable: true
description: Percent of the window's allowance already spent, 0-100.
example: 48
resetsAt:
type: string
format: date-time
nullable: true
description: When the window rolls over, when the account reports one.
CodexConfigScope:
type: string
enum:
@@ -3413,12 +3428,13 @@ components:
permissionMode:
type: string
enum:
- auto
- acceptEdits
- default
- plan
- bypassPermissions
description: Permission mode passed via `--permission-mode`. Defaults to `acceptEdits` when omitted or invalid.
example: acceptEdits
description: Permission mode passed via `--permission-mode`. Defaults to `auto` when omitted or invalid.
example: auto
effort:
type: string
enum:
@@ -3596,12 +3612,13 @@ components:
type: string
nullable: true
enum:
- auto
- acceptEdits
- default
- plan
- bypassPermissions
description: Permission mode recorded at spawn time.
example: acceptEdits
example: auto
effort:
type: string
nullable: true
@@ -3777,13 +3794,14 @@ components:
permissionMode:
type: string
enum:
- auto
- acceptEdits
- default
- plan
- bypassPermissions
default: acceptEdits
description: Permission mode passed via `--permission-mode`. Unknown values silently fall back to `acceptEdits`.
example: acceptEdits
default: auto
description: Permission mode passed via `--permission-mode`. Unknown values silently fall back to `auto`.
example: auto
RunSpawnConcurrencyResponse:
type: object
description: HTTP 429 body when the concurrency cap is reached (ECONCURRENCY). In addition to the standard `error` envelope, this response carries a top-level `running` array listing the runs currently occupying the cap.
@@ -3888,6 +3906,9 @@ components:
type: string
description: Title for the lane if one has to be created; ignored when one already exists.
example: App package
exact:
type: boolean
description: Only a lane on this very directory counts; a parent directory's lane is not returned, so a nested directory gets its own lane.
LaneDestructiveActionRequest:
type: object
required:
@@ -3999,7 +4020,7 @@ components:
required:
- title
- body
description: Notification content to broadcast. Both fields are mandatory; a missing title or body yields a 400. The same title/body is delivered to every reachable surface (native Electron notification + all stored Web Push subscriptions).
description: Notification content to broadcast. `title`/`body` are mandatory; a missing one yields a 400. The same content is delivered to every reachable surface (native Electron notification + all stored Web Push subscriptions).
properties:
title:
type: string
@@ -4009,6 +4030,10 @@ components:
type: string
description: Notification body text.
example: Your Claude Code session finished with 3 subagents.
url:
type: string
description: Optional same-origin path the Web Push delivery navigates to on click, via the service worker's `notificationclick` handler (`client/public/sw.js`). Ignored by the native Electron leg, which has no click handling. Omit for notifications with no specific target.
example: /workspace?lane=3
PushSendResponse:
type: object
required:
@@ -4038,6 +4063,21 @@ components:
minimum: 0
description: Count of Web Push sends that were rejected. Subscriptions rejected with HTTP 410 (Gone) are deleted from `push_subscriptions` as part of this request.
example: 1
errors:
type: array
description: Why the rejected sends were rejected, straight from the push service (capped at 3). Diagnostic only - it never includes the subscription endpoint, which is a capability URL. Drives the Settings test-notification banner, so a delivery that reached nobody names its reason instead of looking like success.
items:
type: object
properties:
statusCode:
type: integer
nullable: true
description: HTTP status the push service answered with, when there was one.
example: 403
message:
type: string
description: Response body or error message, truncated to 200 characters.
example: UnauthorizedRegistration
SessionsFacetsResponse:
type: object
description: 'Facet values for the Sessions page filter UI: the distinct working directories (cwd) and the distinct data-source ids seen across all sessions.'
@@ -6159,6 +6199,7 @@ paths:
heapTotal: 71303168
heapUsed: 58392104
external: 3211884
heapLimit: 4345298944
arrayBuffers: 1048576
cpu_load:
- 2.13
@@ -8938,7 +8979,7 @@ paths:
- CcConfig
summary: Switch the local CLI to another Anthropic account
description: |-
Makes a saved profile the live account. Swaps EXACTLY two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (key-merged in place, and only where the file already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never touched - that is what makes a switch keep the whole workspace and change only the quota. `mcpOAuth` stays put too (machine MCP tokens, not account identity).
Makes a saved profile the live account. Swaps EXACTLY two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json` (on macOS: the login Keychain item `Claude Code-credentials`, which is where the CLI reads it), and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (key-merged in place, and only where the file already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never touched - that is what makes a switch keep the whole workspace and change only the quota. `mcpOAuth` stays put too (machine MCP tokens, not account identity).
The outgoing account is re-snapshotted into its own profile FIRST, so tokens Claude Code refreshed since its last save are not lost. A switch takes effect for NEW Claude Code sessions only, and a session still running will rewrite the config on exit - close them first.
operationId: ccConfigActivateProfile
@@ -8979,6 +9020,72 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/cc-config/profiles/usage:
post:
tags:
- CcConfig
summary: How much of a saved profile's account quota is already spent
description: |-
Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
A POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.
operationId: ccConfigProfileUsage
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
example: work
responses:
'200':
description: Percentages and reset times for the two quota windows.
content:
application/json:
schema:
type: object
properties:
name:
type: string
refreshed:
type: boolean
description: True when the stored token was expired and had to be rotated.
fiveHour:
$ref: '#/components/schemas/CcConfigUsageWindow'
sevenDay:
$ref: '#/components/schemas/CcConfigUsageWindow'
checkedAt:
type: string
format: date-time
'401':
description: '`EUNAUTHORIZED` - the account refused the token even after a refresh.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: '`ENOTFOUND` - no profile by that name.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: '`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'502':
description: '`EUPSTREAM` - Anthropic''s token or usage endpoint did not cooperate.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/codex-config/overview:
get:
tags:
@@ -10529,7 +10636,7 @@ paths:
tags:
- Lanes
summary: Find or adopt the lane owning a working directory
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
operationId: ensureLane
requestBody:
required: true
@@ -10874,9 +10981,256 @@ paths:
description: Absolute path to an existing git repository.
responses:
'200':
description: '{branches: string[], current: string | null}.'
description: '{branches: string[], current: string | null, worktrees: {path, branch?, locked?}[]}. The worktrees ride along so the picker can grey out branches git would refuse to check out twice.'
'400':
description: repo is missing, relative, does not exist, or is not a git repo.
/api/lanes/{id}/git/changes:
get:
tags:
- Lanes
summary: A lane's code changes, at one of three depths
description: Backs the Workspace Changes panel. No extra params = a summary of every changed file; full=1 adds every file's hunks (review mode); path=<file> returns that one file's hunks. range=work (default) is HEAD vs the working tree with untracked files included; range=branch is <base_branch>...HEAD; range=commit needs sha= and shows that one commit. context= widens each hunk (default 3, clamped to 5000) — the viewer's expand button. Lines arrive with both gutters resolved. Capped at 300 files, 200 KB of patch per file and 1 MB per full read; a cap that bit sets `truncated`. Read-only, no same-origin guard.
operationId: getLaneChanges
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: range
in: query
schema:
type: string
enum:
- work
- branch
- commit
default: work
- name: sha
in: query
schema:
type: string
description: 'range=commit only: the commit to show.'
- name: context
in: query
schema:
type: integer
default: 3
maximum: 5000
description: Unchanged lines around each hunk.
- name: path
in: query
schema:
type: string
description: Return only this file, with its hunks.
- name: full
in: query
schema:
type: string
enum:
- '1'
- 'true'
description: Include every file's hunks (review mode).
responses:
'200':
description: '{available, range, base, baseRef, sha, context, files, truncated}, or {available:false} for a cwd git cannot read.'
'400':
description: ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.
'404':
description: Lane not found.
/api/lanes/{id}/git/log:
get:
tags:
- Lanes
summary: The lane's recent commits
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
operationId: listLaneCommits
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: limit
in: query
schema:
type: integer
default: 30
maximum: 200
- name: file
in: query
schema:
type: string
responses:
'200':
description: '{available, commits:[{sha, short, subject, author, date, refs}]}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/file:
get:
tags:
- Lanes
summary: One file's whole content at a revision
description: rev=work (the file on disk, untracked included), HEAD, or a commit-ish. The `work` revision is the only one that reads the filesystem and it is realpath-confined to the lane directory. Binary content is reported, never returned. Capped at 1 MB. Read-only, no same-origin guard.
operationId: readLaneFile
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: path
in: query
required: true
schema:
type: string
- name: rev
in: query
schema:
type: string
default: work
responses:
'200':
description: '{available, path, rev, binary, truncated, lines}.'
'400':
description: EBADPATH, EBADREV or ENOFILE.
'404':
description: Lane not found.
/api/lanes/{id}/git/ls:
get:
tags:
- Lanes
summary: Tracked paths matching a substring (quick-open)
description: Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.
operationId: listLanePaths
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: q
in: query
schema:
type: string
responses:
'200':
description: '{available, paths, truncated, total}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/tree:
get:
tags:
- Lanes
summary: One directory level of tracked files
description: 'The file explorer''s expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.'
operationId: listLaneTree
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: path
in: query
schema:
type: string
default: ''
responses:
'200':
description: '{available, path, entries:[{name, path, dir}], truncated}.'
'400':
description: EBADPATH (the path escapes the lane).
'404':
description: Lane not found.
/api/lanes/{id}/git/branches:
get:
tags:
- Lanes
summary: The lane repo's own local branches
description: Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
operationId: listLaneOwnBranches
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: '{available, branches, current, worktrees}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/{action}:
post:
tags:
- Lanes
summary: One git write on the lane's working copy
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
operationId: runLaneGitAction
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: action
in: path
required: true
schema:
type: string
enum:
- fetch
- pull
- push
- stage
- unstage
- discard
- commit
- switch
- branch-create
- branch-delete
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
message:
type: string
description: commit only; required, max 4000 chars.
only:
type: string
enum:
- staged
description: commit only; commit the index as it stands, with no add -A.
branch:
type: string
description: switch, branch-create and branch-delete.
base:
type: string
description: branch-create only; defaults to HEAD.
create:
type: boolean
description: switch only; create the branch.
paths:
type: array
items:
type: string
description: stage, unstage, discard; each must be a path git reports changed.
confirm:
type: boolean
description: discard only; the call is refused without it.
responses:
'200':
description: '{action, ok:true, output} — git''s own stdout+stderr.'
'400':
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.
'404':
description: Lane not found.
'500':
description: git failed; its stderr is appended to error.message.
/api/lanes/{id}/{action}:
post:
tags:
@@ -11005,7 +11359,7 @@ paths:
tags:
- Push
summary: Broadcast a notification to all surfaces
description: 'Dispatches a notification to every reachable surface at once: it fires a native OS notification via Electron''s main-process Notification API when the server is hosted inside the desktop app, AND sends an encrypted Web Push delivery to every stored subscription. Both legs run unconditionally so whichever surface the user is on receives the alert — under `npm start` the native leg is a no-op, and under the desktop app the Web Push leg is typically a no-op (Electron has no FCM credentials, so `push_subscriptions` is empty). Subscriptions rejected with HTTP 410 (Gone) are pruned from `push_subscriptions` during the request. The response reports `{ native, pushed, failed }` so the caller can tell a real delivery from a silent no-op. No authentication (local-first). A missing `title` or `body` returns 400; an unexpected dispatch error returns 500.'
description: 'Dispatches a notification to every reachable surface at once: it fires a native OS notification via Electron''s main-process Notification API when the server is hosted inside the desktop app, AND sends an encrypted Web Push delivery to every stored subscription. Both legs run unconditionally so whichever surface the user is on receives the alert — under `npm start` the native leg is a no-op, and under the desktop app the Web Push leg is typically a no-op (Electron has no FCM credentials, so `push_subscriptions` is empty). Subscriptions rejected with HTTP 410 (Gone) are pruned from `push_subscriptions` during the request. The response reports `{ native, pushed, failed, errors }` so the caller can tell a real delivery from a silent no-op, and can show WHY a rejected delivery failed. No authentication (local-first). A missing `title` or `body` returns 400; an unexpected dispatch error returns 500.'
operationId: pushSend
requestBody:
required: true
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.6.9",
"version": "1.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.6.9",
"version": "1.8.4",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.6.9",
"version": "1.8.4",
"bin": {
"agenthub": "bin/agenthub.js"
},
+3 -1
View File
@@ -17,7 +17,9 @@ if [ -z "$MARKETPLACE" ]; then
echo "agenthub is not installed as a plugin (checkout install?) - nothing to update this way."
else
claude plugin marketplace update "$MARKETPLACE"
claude plugin update "agenthub@$MARKETPLACE" -y
# An older Claude Code has no `-y` (and no prompt for it to answer).
claude plugin update "agenthub@$MARKETPLACE" -y 2>/dev/null ||
claude plugin update "agenthub@$MARKETPLACE"
fi
```
+15
View File
@@ -83,6 +83,21 @@ function resolveClaude() {
}
function run(cmd, args, timeout) {
try {
return runOnce(cmd, args, timeout);
} catch (err) {
// An older Claude Code rejects `-y` ("unknown option") and never prompts,
// so the same command without the flag is the right retry.
if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err;
return runOnce(
cmd,
args.filter((a) => a !== "-y"),
timeout
);
}
}
function runOnce(cmd, args, timeout) {
log(`$ ${cmd} ${args.join(" ")}`);
const out = execFileSync(cmd, args, {
encoding: "utf8",
+28 -11
View File
@@ -161,6 +161,10 @@ async function parseSessionFile(filePath) {
// First real user prompt (tool-result / meta / command entries skipped) —
// fallback descriptor for sessions that never got a title. First wins.
let firstUserMessage = null;
// Claude Code writes one JSONL line per content block of an assistant
// message, each repeating the SAME message-level `usage`. Count each message
// once — see `lastUsageMsgId` in server/lib/transcript-cache.js.
let lastUsageMsgId = null;
for await (const line of rl) {
if (!line.trim()) continue;
@@ -268,7 +272,10 @@ async function parseSessionFile(filePath) {
const msg = entry.message || {};
const msgModel = msg.model || null;
if (!model && msgModel && msgModel !== "<synthetic>") model = msgModel;
if (msgModel && msgModel !== "<synthetic>" && msg.usage) {
const usageMsgId = typeof msg.id === "string" ? msg.id : null;
const repeatedLine = usageMsgId !== null && usageMsgId === lastUsageMsgId;
if (usageMsgId !== null && msg.usage) lastUsageMsgId = usageMsgId;
if (msgModel && msgModel !== "<synthetic>" && msg.usage && !repeatedLine) {
const usage = msg.usage;
const key = bucketKey(
msgModel,
@@ -402,6 +409,8 @@ async function parseSubagentFile(filePath) {
// Inverting these across a session reconstructs the real spawn hierarchy so
// nested subagents nest under their spawner instead of flattening to main.
const spawnedChildren = new Set();
// One assistant message = several JSONL lines repeating the same `usage`.
let lastUsageMsgId = null;
for await (const line of rl) {
if (!line.trim()) continue;
@@ -453,7 +462,10 @@ async function parseSubagentFile(filePath) {
const msg = entry.message || {};
const msgModel = msg.model || null;
if (!model && msgModel && msgModel !== "<synthetic>") model = msgModel;
if (msgModel && msgModel !== "<synthetic>" && msg.usage) {
const usageMsgId = typeof msg.id === "string" ? msg.id : null;
const repeatedLine = usageMsgId !== null && usageMsgId === lastUsageMsgId;
if (usageMsgId !== null && msg.usage) lastUsageMsgId = usageMsgId;
if (msgModel && msgModel !== "<synthetic>" && msg.usage && !repeatedLine) {
const usage = msg.usage;
const key = bucketKey(
msgModel,
@@ -772,12 +784,16 @@ function combineSessionTokens(session) {
}
/**
* Write a session's per-model token totals via replaceTokenUsage. Safe to call
* repeatedly: the underlying SQL preserves the highest-seen value via the
* baseline_* columns, so a re-run never reduces totals.
* Write a session's per-model token totals. By default uses replaceTokenUsage,
* whose baseline_* columns preserve the highest-seen value so a re-run never
* reduces totals. `opts.exact` switches to setTokenUsage, which overwrites the
* row and clears the baselines — for the reconcile path, where this full
* re-parse of an append-only transcript IS the truth and must be able to
* correct a previously over-counted row.
*/
function writeSessionTokens(dbModule, sessionId, tokensByModel) {
function writeSessionTokens(dbModule, sessionId, tokensByModel, opts = {}) {
const { stmts } = dbModule;
const write = opts.exact ? stmts.setTokenUsage : stmts.replaceTokenUsage;
let written = 0;
for (const tokens of Object.values(tokensByModel || {})) {
if (
@@ -789,7 +805,7 @@ function writeSessionTokens(dbModule, sessionId, tokensByModel) {
(tokens.webFetch || 0) > 0 ||
(tokens.codeExec || 0) > 0
) {
stmts.replaceTokenUsage.run(
write.run(
sessionId,
tokens.model,
tokens.speed,
@@ -2000,16 +2016,17 @@ async function reconcileTokens(dbModule, options = {}) {
}
}
const known = dbModule.db
.prepare("SELECT id FROM sessions WHERE metadata LIKE '%\"imported\":true%'")
.all();
// Every session we can find a transcript for — not just imported ones. A
// live-hook session that has ENDED never gets another event, so a re-parse
// here is the only way its stored totals can ever be corrected.
const known = dbModule.db.prepare("SELECT id FROM sessions").all();
const total = known.length;
let processed = 0;
const tx = dbModule.db.transaction((batch) => {
for (const { sessionId, tokens } of batch) {
const written = writeSessionTokens(dbModule, sessionId, tokens);
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact: true });
if (written > 0) {
counters.sessionsTouched++;
counters.modelsWritten += written;
+29 -1
View File
@@ -170,6 +170,15 @@ function fastPathOk(rt = runtimeDir()) {
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
// path; `unitIsStale` deliberately spawns nothing.
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
// Leftover checkout hooks are the one defect that corrupts the DATA rather
// than the install: events carry no id, so a second handler doubles every
// event, every subagent row and every turn. `stripLegacyHooks` removes them —
// but it runs inside `bootstrap()`, which this fast path skips, so on a
// machine that is otherwise healthy they survived every session forever.
// Reading one JSON file is cheap enough here; the strip still happens in the
// worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one
// Task spawning two subagent rows.)
if (hasLegacyHooks()) return false;
return serverIsLive();
}
@@ -291,6 +300,24 @@ function isCheckoutHookEntry(claudeHome = getClaudeHome()) {
*
* @returns {number} how many entries were removed
*/
/**
* Does `~/.claude/settings.json` still carry hook entries from a checkout
* install? Pure fs, no writes — the fast path calls it on every SessionStart.
*
* @returns {boolean}
*/
function hasLegacyHooks(settingsPath = getSettingsPath()) {
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
} catch {
return false;
}
if (!settings.hooks || typeof settings.hooks !== "object") return false;
const isLegacy = isCheckoutHookEntry();
return Object.values(settings.hooks).some((e) => Array.isArray(e) && e.some(isLegacy));
}
function stripLegacyHooks(settingsPath = getSettingsPath()) {
let settings;
try {
@@ -507,7 +534,7 @@ function autoInstallSkillPlugins(rt, { force = false } = {}) {
/* ------------------------------------------------------ plugin-cache pruning */
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
const KEEP_RETIRED_ROOTS = 2;
const KEEP_RETIRED_ROOTS = 1;
/**
* Delete the cache directories earlier updates left behind.
@@ -875,6 +902,7 @@ module.exports = {
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
hasLegacyHooks,
linkCli,
installDeps,
serverIsLive,
+15
View File
@@ -102,6 +102,21 @@ function findNewRoot(exclude) {
}
function run(cmd, args, timeout) {
try {
return runOnce(cmd, args, timeout);
} catch (err) {
// An older Claude Code rejects `-y` ("unknown option") and never prompts,
// so the same command without the flag is the right retry.
if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err;
return runOnce(
cmd,
args.filter((a) => a !== "-y"),
timeout
);
}
}
function runOnce(cmd, args, timeout) {
log(`$ ${cmd} ${args.join(" ")}`);
const out = execFileSync(cmd, args, {
encoding: "utf8",
+26 -4
View File
@@ -524,6 +524,14 @@ Request body shape:
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream). `{available:false}` with `200` for a cwd git cannot read. |
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response lane starts as
@@ -583,10 +591,9 @@ journal's mtime, under the guard hook ingestion uses (no `run_id`, only from
Getting a codex lane's *declared* stage to actually happen is a separate
problem — only the session itself can call `agenthub stage`, and unlike a Claude
lane it has no slash command to pre-fill. `LaneConsolePane.tsx` pre-fills a
codex pane's opening prompt with a plain-text checklist instead, one
`agenthub stage <stage> --tool codex` line per node of the lane's own
`pipeline_nodes`. As a safety net beyond that opening nudge,
lane it has no slash command to pre-fill — and the run console pre-fills
nothing into the prompt box any more, for either tool, since anything sitting
there is sent as a real prompt on start. What is left is
`server/lib/codex-nudge.js`'s `sweepCodexNudges()` runs on an unref'd 60s
interval (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables): when a codex lane's fresh
`detected_stage` has sat ahead of its declared `stage` for at least two
@@ -713,6 +720,21 @@ calculations match real-time captured sessions exactly. Re-imports are
idempotent (dedupe by session ID; compaction `baseline_*` columns
prevent token double-counting).
Both parsers count each assistant message's `usage` **once**: Claude Code
writes one JSONL line per content block (thinking / text / each `tool_use`)
and every one repeats the same message-level totals, so summing lines
inflated a tool-heavy session's tokens — and its cost — by ~1.5–2×. The
guard is the previous message id (`lastUsageMsgId`), carried across
incremental reads so a message split over a chunk boundary still counts once.
`reconcileTokens` (`node scripts/import-history.js --reconcile-tokens`)
re-parses every session that still has a transcript on disk and writes the
result with `stmts.setTokenUsage`, which overwrites the row and clears the
baselines. That is deliberate: the hook writer's `replaceTokenUsage` keeps a
high-water mark, so without an exact write a previously over-counted total
could never be corrected downwards. The server runs this reconcile once, in
the background, at the first start after upgrading (`user_version` 2).
Imported and live-scanned subagents also get their **nested hierarchy**
rebuilt: rows are inserted flat under the main agent, then
`reconcileSubagentParents` recovers each spawner from the subagent
+3 -3
View File
@@ -216,13 +216,13 @@ describe("Event-driven alert evaluation", () => {
const ruleId = created.body.rule.id;
const sessionId = `alerts-burst-${Date.now()}`;
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId, seq: 1 });
await postHook("BurstProbe", { session_id: sessionId, seq: 2 });
let feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId, seq: 3 });
feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
+39
View File
@@ -617,6 +617,37 @@ describe("Hook Event Processing", () => {
assert.equal(res.body.error.code, "MISSING_SESSION");
});
it("stores a hook delivered twice only once (double registration)", async () => {
const hook = {
hook_type: "PreToolUse",
data: {
session_id: "hook-dup-1",
tool_name: "Bash",
tool_use_id: "toolu_dup_1",
tool_input: { command: "ls", description: "List files" },
},
};
const first = await post("/api/hooks/event", hook);
const second = await post("/api/hooks/event", hook);
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(second.body.duplicate, true);
const { n } = db
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'hook-dup-1'")
.get();
assert.equal(n, 1);
// A DIFFERENT payload in the same session still lands.
await post("/api/hooks/event", {
...hook,
data: { ...hook.data, tool_use_id: "toolu_dup_2" },
});
const after = db
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'hook-dup-1'")
.get();
assert.equal(after.n, 2);
});
it("should auto-create session and main agent on first PreToolUse", async () => {
const res = await post("/api/hooks/event", {
hook_type: "PreToolUse",
@@ -1865,6 +1896,14 @@ describe("Transcript cache integration", () => {
assert.equal(res.body.server.version, pkg.version);
});
it("reports the V8 heap LIMIT, not just the currently allocated heapTotal", async () => {
const res = await fetch("/api/settings/info");
const { heapUsed, heapTotal, heapLimit } = res.body.server.memory;
assert.ok(Number.isFinite(heapLimit), "heapLimit should be a number");
// heapTotal is what V8 has grown to so far; the limit is the ceiling.
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
});
it("should evict cache entry on SessionEnd", async () => {
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
fs.writeFileSync(
@@ -606,6 +606,24 @@ describe("patchSettings — backup and atomicity", () => {
assert.deepEqual(backup, original);
});
it("writes and backs up nothing when the patch changes nothing", () => {
// Re-attaching the same AI Router profile sends the same eight env keys
// every time. With an unconditional backup that left a `.bak` per apply
// piling up inside the user's own project — a dozen untracked files in a
// minute of switching lanes.
const file = path.join(FAKE_HOME, "settings.json");
ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
const before = fs.readFileSync(file, "utf8");
const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
assert.equal(result.backupPath, null);
assert.deepEqual(result.changed, ["model"], "the request was still satisfied");
assert.equal(fs.readFileSync(file, "utf8"), before);
const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings");
assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []);
});
it("returns null backupPath for a fresh file", () => {
const result = ccMutate.patchSettings({
scope: "user",
+16
View File
@@ -116,6 +116,22 @@ describe("cc-plugin-cli argv construction", () => {
}
});
it("retries without -y on a Claude Code too old to know the flag", async () => {
// Older CLIs answer `error: unknown option '-y'` and exit 1; they also
// never prompt, so the same command without the flag is the right retry.
const restore = stubClaude(
'for a in "$@"; do [ "$a" = "-y" ] && { echo "error: unknown option \'-y\'" >&2; exit 1; }; done\n' +
'for a in "$@"; do echo "$a"; done'
);
try {
const r = await cli.pluginUpdate({ spec: "demo@m" });
assert.equal(r.ok, true);
assert.deepEqual(argvOf(r), ["plugin", "update", "demo@m"]);
} finally {
restore();
}
});
it("passes -y on install so a non-TTY run never waits for a prompt", async () => {
const restore = stubClaude();
try {
+53
View File
@@ -11,6 +11,9 @@ const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cc-profiles-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
// File-backed by default, so running this suite on a Mac never reads or rewrites
// the developer's real login Keychain. The Keychain path has its own test below.
process.env.AGENTHUB_CC_KEYCHAIN = "0";
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
@@ -325,6 +328,56 @@ describe("cc-profiles", () => {
);
});
it("switches the account in the macOS Keychain, where the CLI actually reads it", () => {
const cp = require("node:child_process");
const realExec = cp.execFileSync;
const realPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
process.env.AGENTHUB_CC_KEYCHAIN = "1";
// A fake `security` binary backed by one string — the keychain item.
let item = null;
cp.execFileSync = (_bin, args) => {
if (args[0] === "find-generic-password") {
if (item === null) throw new Error("SecKeychainSearchCopyNext: not found");
return item;
}
if (args[0] === "add-generic-password") {
item = args[args.indexOf("-w") + 1];
return "";
}
throw new Error(`unexpected security verb ${args[0]}`);
};
try {
// Logged in as A, Keychain-only: no `.credentials.json` exists at all.
login("uuid-a", "a@example.com");
fs.rmSync(CREDS, { force: true });
item = JSON.stringify({
claudeAiOauth: { accessToken: "at-uuid-a", refreshToken: "rt-uuid-a" },
});
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
fs.rmSync(CREDS, { force: true });
item = JSON.stringify({
claudeAiOauth: { accessToken: "at-uuid-b", refreshToken: "rt-uuid-b" },
});
profiles.saveProfile("personal");
assert.equal(profiles.listProfiles().items.find((p) => p.active).name, "personal");
const result = profiles.activateProfile("work");
assert.equal(JSON.parse(item).claudeAiOauth.accessToken, "at-uuid-a");
assert.ok(
result.written.some((w) => w.includes("Keychain")),
"the switch must report the Keychain as written"
);
assert.equal(fs.existsSync(CREDS), false, "no .credentials.json is invented on macOS");
} finally {
cp.execFileSync = realExec;
Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
process.env.AGENTHUB_CC_KEYCHAIN = "0";
}
});
it("reports a corrupt live credentials file rather than guessing", () => {
fs.mkdirSync(HOME, { recursive: true });
fs.writeFileSync(CREDS, "{nope");
+267
View File
@@ -0,0 +1,267 @@
/**
* @file cc-usage.test.js
* @description Tests for per-profile quota lookup.
*
* Two properties carry the whole feature. A profile's refreshed OAuth tokens
* must be written back — the exchange retires the refresh token it was given,
* so dropping the result leaves an account the user has to log into again. And
* a still-valid token must NOT be refreshed, because every refresh rewrites
* credentials on disk and the point of a read is to read.
*
* `fetch` is stubbed throughout: nothing here talks to Anthropic.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-"));
process.env.CLAUDE_HOME = TMP;
const profiles = require("../lib/cc-profiles");
const usage = require("../lib/cc-usage");
const realFetch = global.fetch;
let calls = [];
after(() => {
global.fetch = realFetch;
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
} catch {
/* best-effort temp cleanup */
}
});
function writeProfileFile(name, credentials) {
const dir = profiles.profilesDir();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `${name}.json`),
JSON.stringify({
name,
label: "Label kept",
savedAt: 1,
account: { emailAddress: "a@b.c" },
credentials,
})
);
}
/** Answer the token endpoint and the usage endpoint, recording both. */
function stubFetch({ usageStatus = 200, usageBody } = {}) {
global.fetch = async (url, init) => {
calls.push({ url: String(url), init });
if (String(url).includes("/v1/oauth/token")) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
access_token: "fresh-access",
refresh_token: "rotated-refresh",
expires_in: 28800,
}),
};
}
return {
ok: usageStatus === 200,
status: usageStatus,
json: async () =>
usageBody || {
five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" },
seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" },
seven_day_opus: null,
},
};
};
}
beforeEach(() => {
calls = [];
fs.rmSync(profiles.profilesDir(), { recursive: true, force: true });
});
afterEach(() => {
global.fetch = realFetch;
});
describe("profileUsage", () => {
it("reports the two windows, and only those", async () => {
writeProfileFile("live", {
accessToken: "still-good",
refreshToken: "r",
expiresAt: Date.now() + 3_600_000,
});
stubFetch();
const result = await usage.profileUsage("live");
assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" });
assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" });
// Nothing token-shaped may be in what a route is about to serialise.
assert.equal(JSON.stringify(result).includes("still-good"), false);
});
it("does not refresh a token that is still valid", async () => {
writeProfileFile("live", {
accessToken: "still-good",
refreshToken: "r",
expiresAt: Date.now() + 3_600_000,
});
stubFetch();
const result = await usage.profileUsage("live");
assert.equal(result.refreshed, false);
assert.equal(
calls.some((c) => c.url.includes("/v1/oauth/token")),
false,
"a read must not rewrite credentials on disk"
);
assert.match(calls[0].init.headers.Authorization, /still-good/);
});
it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => {
// The exchange retires the refresh token it was handed. A refresh whose
// result is not persisted leaves the profile holding a dead credential —
// the account could never be activated again without a fresh login.
writeProfileFile("stale", {
accessToken: "expired",
refreshToken: "original-refresh",
expiresAt: Date.now() - 1000,
subscriptionType: "team",
});
stubFetch();
const result = await usage.profileUsage("stale");
assert.equal(result.refreshed, true);
const stored = profiles.readProfile("stale");
assert.equal(stored.credentials.accessToken, "fresh-access");
assert.equal(stored.credentials.refreshToken, "rotated-refresh");
assert.ok(stored.credentials.expiresAt > Date.now());
// Everything else about the profile survives the rewrite.
assert.equal(stored.label, "Label kept");
assert.equal(stored.credentials.subscriptionType, "team");
assert.equal(stored.account.emailAddress, "a@b.c");
});
it("retries once through a refresh when a token that looked valid is refused", async () => {
writeProfileFile("revoked", {
accessToken: "revoked-but-unexpired",
refreshToken: "original-refresh",
expiresAt: Date.now() + 3_600_000,
});
let first = true;
global.fetch = async (url) => {
calls.push({ url: String(url) });
if (String(url).includes("/v1/oauth/token")) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }),
};
}
if (first) {
first = false;
return { ok: false, status: 401, json: async () => ({}) };
}
return {
ok: true,
status: 200,
json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }),
};
};
const result = await usage.profileUsage("revoked");
assert.equal(result.refreshed, true);
assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null });
});
it("refuses a profile with no refresh token instead of half-answering", async () => {
writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 });
stubFetch();
await assert.rejects(
() => usage.profileUsage("tokenless"),
(e) => e.code === "ENOREFRESH"
);
});
it("uses the CLI's own token for the account that is logged in, not the saved copy", async () => {
// The saved copy of the LIVE account is the one set of credentials certain
// to fail: the CLI has refreshed since, and that retired the refresh token
// sitting in the profile file. Refreshing it here would also rotate the
// token the user's terminal is using.
const account = { accountUuid: "uuid-live", emailAddress: "a@b.c" };
fs.mkdirSync(profiles.profilesDir(), { recursive: true });
fs.writeFileSync(
path.join(profiles.profilesDir(), "mine.json"),
JSON.stringify({
name: "mine",
account,
credentials: { accessToken: "retired", refreshToken: "retired-r", expiresAt: 1 },
})
);
fs.writeFileSync(
path.join(TMP, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "live-token",
refreshToken: "live-r",
expiresAt: Date.now() + 3_600_000,
},
})
);
fs.writeFileSync(path.join(TMP, ".claude.json"), JSON.stringify({ oauthAccount: account }));
stubFetch();
const result = await usage.profileUsage("mine");
assert.equal(result.refreshed, false);
assert.match(calls[0].init.headers.Authorization, /live-token/);
assert.equal(
calls.some((c) => c.url.includes("/v1/oauth/token")),
false,
"refreshing the live account would log the user's own terminal out"
);
fs.rmSync(path.join(TMP, ".credentials.json"));
fs.rmSync(path.join(TMP, ".claude.json"));
});
it("names a spent refresh token instead of echoing the upstream 400", async () => {
writeProfileFile("superseded", {
accessToken: "expired",
refreshToken: "already-rotated",
expiresAt: Date.now() - 1000,
});
global.fetch = async (url) => {
calls.push({ url: String(url) });
return {
ok: false,
status: 400,
text: async () => '{"error": "invalid_grant", "error_description": "not found"}',
};
};
await assert.rejects(
() => usage.profileUsage("superseded"),
(e) => e.code === "ESTALE" && /save the profile again/.test(e.message)
);
});
it("reports an unknown profile as ENOTFOUND", async () => {
stubFetch();
await assert.rejects(
() => usage.profileUsage("nope"),
(e) => e.code === "ENOTFOUND"
);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* @file desktop-env.test.js
* @description Unit tests for the graphical-session variables forwarded into a
* lane's tmux pane. Without them a `claude` started by the dashboard has no
* DISPLAY/WAYLAND_DISPLAY at all and answers "no image found in clipboard" on
* a paste, while the same CLI works in a normal terminal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const saved = {};
describe("desktopEnv", () => {
beforeEach(() => {
for (const name of FORWARDED) {
saved[name] = process.env[name];
delete process.env[name];
}
});
afterEach(() => {
for (const name of FORWARDED) {
if (saved[name] === undefined) delete process.env[name];
else process.env[name] = saved[name];
}
});
it("reads the systemd user manager's environment", () => {
__setExecImpl(
() => "XDG_RUNTIME_DIR=/run/user/1001\nDISPLAY=:0\nXAUTHORITY=/run/user/1001/gdm/Xauthority\n"
);
assert.deepEqual(desktopEnv(), {
DISPLAY: ":0",
XAUTHORITY: "/run/user/1001/gdm/Xauthority",
XDG_RUNTIME_DIR: "/run/user/1001",
});
});
it("prefers this process's own value — a checkout run from a terminal has the real one", () => {
process.env.DISPLAY = ":1";
__setExecImpl(() => "DISPLAY=:0\n");
assert.equal(desktopEnv().DISPLAY, ":1");
});
it("forwards nothing when there is no systemd user manager (macOS, a container)", () => {
__setExecImpl(() => {
throw new Error("systemctl: not found");
});
assert.deepEqual(desktopEnv(), {});
});
it("forwards only the clipboard-related names, never the whole session", () => {
__setExecImpl(() => "DISPLAY=:0\nSSH_AUTH_SOCK=/tmp/agent\nPATH=/usr/bin\n");
assert.deepEqual(Object.keys(desktopEnv()), ["DISPLAY"]);
});
it("drops a value carrying a newline — it becomes a tmux -e argv", () => {
__setExecImpl(() => "");
process.env.WAYLAND_DISPLAY = "wayland-0\ninjected";
assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined);
});
});
+554
View File
@@ -0,0 +1,554 @@
/**
* @file Tests for server/lib/git-diff.js and server/lib/git-ops.js against a
* REAL git fixture (a bare origin, a clone, a second clone that pushes behind
* the first one's back). Diff parsing, --ff-only's refusal, the dirty-tree
* guard on `switch` and the one-worktree-per-branch rule are all git's own
* behavior — a mocked git would only test our idea of it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
collectChanges,
listCommits,
listPaths,
listTree,
parseUnifiedDiff,
readFileAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
const ORIGIN = path.join(ROOT, "origin.git");
const LANE = path.join(ROOT, "lane");
const OTHER = path.join(ROOT, "other");
const g = (cwd, ...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args);
/** `git status --porcelain` as lines — the XY columns matter, so never trim. */
function porcelain(dir) {
return g(dir, "status", "--porcelain").split("\n").filter(Boolean);
}
function write(dir, file, body) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
function freshFixture() {
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(ROOT, { recursive: true });
g(ROOT, "init", "-q", "--bare", ORIGIN);
const seed = path.join(ROOT, "seed");
g(ROOT, "init", "-q", "-b", "development", seed);
write(seed, "a.txt", "one\ntwo\nthree\n");
write(seed, "keep.txt", "unchanged\n");
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(ROOT, "clone", "-q", ORIGIN, LANE);
g(ROOT, "clone", "-q", ORIGIN, OTHER);
// runGitAction's `commit` uses the repo's own identity (it never injects
// one), so the fixture sets a local one — a CI box may have no global user.
for (const dir of [LANE, OTHER]) {
g(dir, "config", "user.email", "t@h");
g(dir, "config", "user.name", "t");
}
gc(LANE, "checkout", "-qb", "feat/thing");
}
before(freshFixture);
describe("parseUnifiedDiff", () => {
it("resolves both gutters across a hunk", () => {
const hunks = parseUnifiedDiff(
"@@ -10,3 +10,4 @@ function f()\n ctx\n-gone\n+new1\n+new2\n ctx2\n"
);
assert.equal(hunks.length, 1);
assert.equal(hunks[0].context, "function f()");
assert.deepEqual(
hunks[0].lines.map((l) => [l.type, l.oldLine, l.newLine, l.text]),
[
["ctx", 10, 10, "ctx"],
["del", 11, null, "gone"],
["add", null, 11, "new1"],
["add", null, 12, "new2"],
["ctx", 12, 13, "ctx2"],
]
);
});
it("ignores the preamble and the no-newline marker", () => {
const hunks = parseUnifiedDiff(
"diff --git a/x b/x\nindex 1..2 100644\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n"
);
assert.equal(hunks.length, 1);
assert.equal(hunks[0].lines.length, 2);
});
it("returns no hunks for an empty patch", () => {
assert.deepEqual(parseUnifiedDiff(""), []);
});
});
describe("collectChanges — work range", () => {
beforeEach(freshFixture);
it("summarizes tracked edits, deletions and untracked files without hunks", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\nfour\n");
fs.rmSync(path.join(LANE, "keep.txt"));
write(LANE, "new.txt", "fresh\nlines\n");
const out = await collectChanges(LANE, { range: "work" });
assert.equal(out.range, "work");
assert.equal(out.truncated, false);
const byPath = Object.fromEntries(out.files.map((f) => [f.path, f]));
assert.deepEqual(Object.keys(byPath).sort(), ["a.txt", "keep.txt", "new.txt"]);
assert.equal(byPath["a.txt"].added, 2);
assert.equal(byPath["a.txt"].deleted, 1);
assert.equal(byPath["keep.txt"].deleted, 1);
assert.equal(byPath["new.txt"].untracked, true);
assert.equal(byPath["new.txt"].added, 2);
// Overview mode is a summary: no file carries hunks.
assert.ok(out.files.every((f) => f.hunks === undefined));
});
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 });
assert.equal(out.files.length, 1);
assert.equal(out.files[0].hunks.length, 1);
const kinds = out.files[0].hunks[0].lines.map((l) => l.type);
assert.ok(kinds.includes("del") && kinds.includes("add"));
});
it("synthesizes an all-added patch for an untracked file", async () => {
write(LANE, "new.txt", "alpha\nbeta\n");
const out = await collectChanges(LANE, { range: "work", file: "new.txt" });
assert.equal(out.files.length, 1);
const lines = out.files[0].hunks[0].lines;
assert.deepEqual(
lines.map((l) => [l.type, l.newLine, l.text]),
[
["add", 1, "alpha"],
["add", 2, "beta"],
]
);
});
it("rejects a path with no changes in the range", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "work", file: "keep.txt" }), {
code: "ENOFILECHANGE",
});
});
it("still returns content when a huge file has no newline to cut on", async () => {
// A minified bundle is ONE line. Cutting the patch at the last newline
// inside the cap would land at index -1 here and hand back an empty
// patch — a file changed by a megabyte rendering as "no textual change".
const { MAX_FILE_BYTES } = require("../lib/git-diff");
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
const entry = out.files[0];
assert.equal(entry.truncated, true);
assert.equal(out.truncated, true);
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
});
it("reports a binary file without trying to diff it", async () => {
fs.writeFileSync(path.join(LANE, "blob.bin"), Buffer.from([0, 1, 2, 0, 3]));
const out = await collectChanges(LANE, { range: "work", full: true });
const blob = out.files.find((f) => f.path === "blob.bin");
assert.equal(blob.binary, true);
assert.deepEqual(blob.hunks, []);
});
});
describe("collectChanges — branch range", () => {
beforeEach(freshFixture);
it("diffs the branch against its base, ignoring uncommitted work", async () => {
write(LANE, "a.txt", "one\ntwo\nthree\nfour\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "add four");
write(LANE, "scratch.txt", "not committed\n");
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
assert.equal(out.baseRef, "origin/development");
assert.deepEqual(
out.files.map((f) => f.path),
["a.txt"]
);
assert.equal(out.files[0].added, 1);
});
it("refuses when the lane has no base to measure against", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: null }), {
code: "ENOBASE",
});
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
code: "ENOBASE",
});
});
});
describe("gitFacts tracking counts", () => {
beforeEach(freshFixture);
it("reports null ahead/behind for a branch with no upstream", async () => {
const facts = await gitFacts(LANE);
assert.equal(facts.branch, "feat/thing");
assert.equal(facts.hasRemote, true);
assert.equal(facts.upstream, null);
assert.equal(facts.ahead, null);
});
it("counts both sides once an upstream exists", async () => {
gc(LANE, "checkout", "-q", "development");
write(LANE, "a.txt", "one\ntwo\nthree\nlocal\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "local");
write(OTHER, "b.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote");
gc(OTHER, "push", "-q", "origin", "development");
g(LANE, "fetch", "-q", "origin");
const facts = await gitFacts(LANE);
assert.equal(facts.upstream, "origin/development");
assert.equal(facts.ahead, 1);
assert.equal(facts.behind, 1);
});
});
describe("runGitAction", () => {
beforeEach(freshFixture);
it("rejects an unknown action", async () => {
await assert.rejects(() => runGitAction(LANE, "rebase"), { code: "EBADACTION" });
});
it("commits everything with the given message", async () => {
write(LANE, "a.txt", "changed\n");
write(LANE, "added.txt", "new\n");
const result = await runGitAction(LANE, "commit", { message: "feat: both files" });
assert.equal(result.ok, true);
assert.equal(g(LANE, "log", "-1", "--format=%s").trim(), "feat: both files");
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
});
it("refuses a commit with no message and one with nothing to commit", async () => {
await assert.rejects(() => runGitAction(LANE, "commit", { message: " " }), {
code: "EBADMESSAGE",
});
await assert.rejects(() => runGitAction(LANE, "commit", { message: "empty" }), {
code: "ENOCHANGES",
});
});
it("pulls fast-forward only and refuses a diverged branch", async () => {
gc(LANE, "checkout", "-q", "development");
write(OTHER, "b.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote");
gc(OTHER, "push", "-q", "origin", "development");
const ff = await runGitAction(LANE, "pull");
assert.equal(ff.ok, true);
assert.ok(fs.existsSync(path.join(LANE, "b.txt")));
// Diverge: a local commit here, a different one pushed there.
write(LANE, "c.txt", "local\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "local");
write(OTHER, "d.txt", "remote2\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote2");
gc(OTHER, "push", "-q", "origin", "development");
await assert.rejects(() => runGitAction(LANE, "pull"));
// The refusal left no merge in progress.
assert.ok(!fs.existsSync(path.join(LANE, ".git", "MERGE_HEAD")));
});
it("pushes the current branch and sets its upstream", async () => {
write(LANE, "a.txt", "changed\n");
await runGitAction(LANE, "commit", { message: "wip" });
const result = await runGitAction(LANE, "push");
assert.equal(result.ok, true);
assert.equal(
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
"origin/feat/thing"
);
});
it("switches branches only with a clean tree", async () => {
write(LANE, "a.txt", "dirty\n");
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
code: "EDIRTY",
});
gc(LANE, "checkout", "-q", "--", "a.txt");
const result = await runGitAction(LANE, "switch", { branch: "development" });
assert.equal(result.branch, "development");
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "development");
});
it("creates a branch only when it does not already exist", async () => {
await runGitAction(LANE, "switch", { branch: "feat/second", create: true });
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/second");
await assert.rejects(
() => runGitAction(LANE, "switch", { branch: "feat/thing", create: true }),
{
code: "EBRANCHEXISTS",
}
);
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "nope/missing" }), {
code: "ENOBRANCH",
});
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "..bad" }), {
code: "EBADBRANCH",
});
});
it("refuses a branch another worktree holds", async () => {
const wt = path.join(ROOT, "sibling");
g(LANE, "worktree", "add", "-q", wt, "development");
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
code: "EBRANCHBUSY",
});
});
});
describe("collectChanges — commit range", () => {
beforeEach(freshFixture);
it("reads one commit's files and hunks", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
gc(LANE, "commit", "-qam", "tweak a");
const sha = g(LANE, "rev-parse", "HEAD").trim();
const out = await collectChanges(LANE, { range: "commit", sha, full: true });
assert.equal(out.sha, sha);
assert.deepEqual(
out.files.map((f) => f.path),
["a.txt"]
);
assert.ok(out.files[0].hunks.some((h) => h.lines.some((l) => l.type === "add")));
});
it("rejects a sha that is not a commit here", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "nope" }), {
code: "EBADREV",
});
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "a b;rm" }), {
code: "EBADREV",
});
});
it("widens a hunk when context is raised", async () => {
write(LANE, "long.txt", Array.from({ length: 40 }, (_, i) => `line${i}`).join("\n") + "\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "long");
write(
LANE,
"long.txt",
Array.from({ length: 40 }, (_, i) => (i === 20 ? "CHANGED" : `line${i}`)).join("\n") + "\n"
);
const narrow = await collectChanges(LANE, { range: "work", file: "long.txt" });
const wide = await collectChanges(LANE, { range: "work", file: "long.txt", context: 20 });
const count = (o) => o.files[0].hunks.reduce((n, h) => n + h.lines.length, 0);
assert.equal(narrow.context, 3);
assert.equal(wide.context, 20);
assert.ok(count(wide) > count(narrow));
});
});
describe("listCommits / readFileAt / listPaths / listTree", () => {
beforeEach(freshFixture);
it("lists commits, newest first, and narrows to one file", async () => {
write(LANE, "a.txt", "two\n");
gc(LANE, "commit", "-qam", "touch a");
write(LANE, "keep.txt", "edited\n");
gc(LANE, "commit", "-qam", "touch keep");
const all = await listCommits(LANE, { limit: 10 });
assert.deepEqual(
all.map((c) => c.subject),
["touch keep", "touch a", "init"]
);
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
const forA = await listCommits(LANE, { file: "a.txt" });
assert.deepEqual(
forA.map((c) => c.subject),
["touch a", "init"]
);
});
it("reads a file at work and at a revision, and refuses to escape the lane", async () => {
write(LANE, "a.txt", "one\nEDITED\n");
const work = await readFileAt(LANE, "a.txt", "work");
assert.deepEqual(work.lines, ["one", "EDITED"]);
const head = await readFileAt(LANE, "a.txt", "HEAD");
assert.deepEqual(head.lines, ["one", "two", "three"]);
await assert.rejects(() => readFileAt(LANE, "../../etc/passwd", "work"), { code: "EBADPATH" });
await assert.rejects(() => readFileAt(LANE, "/etc/passwd", "work"), { code: "EBADPATH" });
await assert.rejects(() => readFileAt(LANE, "a.txt", "no such rev"), { code: "EBADREV" });
await assert.rejects(() => readFileAt(LANE, "ghost.txt", "work"), { code: "ENOFILE" });
});
it("quick-opens by substring, basename hits first, ignoring untracked files", async () => {
write(LANE, "src/api.ts", "x\n");
write(LANE, "src/api/helper.ts", "x\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "untracked-api.ts", "x\n");
const out = await listPaths(LANE, { q: "api" });
assert.equal(out.paths[0], "src/api.ts");
assert.ok(out.paths.includes("src/api/helper.ts"));
assert.ok(!out.paths.includes("untracked-api.ts"));
});
it("folds the tree one directory level at a time", async () => {
write(LANE, "src/api/helper.ts", "x\n");
write(LANE, "src/main.ts", "x\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
const root = await listTree(LANE, {});
assert.deepEqual(
root.entries.map((e) => [e.name, e.dir]),
[
["src", true],
["a.txt", false],
["keep.txt", false],
]
);
const src = await listTree(LANE, { subdir: "src" });
assert.deepEqual(
src.entries.map((e) => e.path),
["src/api", "src/main.ts"]
);
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
});
});
describe("runGitAction — per-file staging, discard and branches", () => {
beforeEach(freshFixture);
it("stages and unstages one file, leaving the other alone", async () => {
write(LANE, "a.txt", "edited\n");
write(LANE, "keep.txt", "also edited\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
assert.deepEqual(porcelain(LANE), ["M a.txt", " M keep.txt"]);
await runGitAction(LANE, "unstage", { path: "a.txt" });
assert.deepEqual(porcelain(LANE), [" M a.txt", " M keep.txt"]);
});
it("refuses a path git is not reporting as changed", async () => {
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["keep.txt"] }), {
code: "EBADPATH",
});
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["../../etc/passwd"] }), {
code: "EBADPATH",
});
await assert.rejects(() => runGitAction(LANE, "stage", {}), { code: "EBADPATH" });
});
it("commits only the index when asked", async () => {
write(LANE, "a.txt", "staged\n");
write(LANE, "keep.txt", "left behind\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
const out = await runGitAction(LANE, "commit", { message: "only a", only: "staged" });
assert.equal(out.ok, true);
assert.deepEqual(porcelain(LANE), [" M keep.txt"]);
});
it("refuses a staged-only commit with an empty index", async () => {
write(LANE, "a.txt", "unstaged\n");
await assert.rejects(() => runGitAction(LANE, "commit", { message: "nope", only: "staged" }), {
code: "ENOCHANGES",
});
});
it("discards a tracked file and deletes an untracked one, but only on confirm", async () => {
write(LANE, "a.txt", "edited\n");
write(LANE, "junk.txt", "throwaway\n");
await assert.rejects(() => runGitAction(LANE, "discard", { paths: ["a.txt"] }), {
code: "ECONFIRM",
});
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "edited\n");
await runGitAction(LANE, "discard", { paths: ["a.txt", "junk.txt"], confirm: true });
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "one\ntwo\nthree\n");
assert.ok(!fs.existsSync(path.join(LANE, "junk.txt")));
});
it("discards a staged edit in both gutters", async () => {
write(LANE, "a.txt", "edited\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
await runGitAction(LANE, "discard", { paths: ["a.txt"], confirm: true });
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
});
it("creates a branch without moving HEAD, and deletes it again", async () => {
const before = g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim();
await runGitAction(LANE, "branch-create", { branch: "feat/sibling" });
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), before);
assert.ok(g(LANE, "branch", "--list", "feat/sibling").trim());
await assert.rejects(() => runGitAction(LANE, "branch-create", { branch: "feat/sibling" }), {
code: "EBRANCHEXISTS",
});
await runGitAction(LANE, "branch-delete", { branch: "feat/sibling" });
assert.equal(g(LANE, "branch", "--list", "feat/sibling").trim(), "");
});
it("refuses to delete a protected, current, missing or busy branch", async () => {
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "development" }), {
code: "EPROTECTED",
});
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/thing" }), {
code: "EBRANCHBUSY",
});
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "nope" }), {
code: "ENOBRANCH",
});
await runGitAction(LANE, "branch-create", { branch: "feat/held" });
g(LANE, "worktree", "add", "-q", path.join(ROOT, "held"), "feat/held");
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/held" }), {
code: "EBRANCHBUSY",
});
});
});

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