Compare commits

...

145 Commits

Author SHA1 Message Date
nntrivi2001 52392962fb chore(release): bump version to 1.8.9 2026-09-25 11:41:53 +07:00
nntrivi2001 40fe41bb77 fix(db): keep router_profile through the legacy lanes rebuild
The (cwd, tool) rebuild lists its columns by hand and omitted
router_profile, which its probe adds earlier in the same start — a legacy
DB lost the column (and every lane's router) until the next restart.
2026-09-25 11:30:10 +07:00
nntrivi2001 99a2a8277c fix(tmux,run,hooks): the four pre-existing bugs the #37 evaluation found
- tmux targets are exact (`=name`, `=name:` for panes/options): a bare
  `-t agenthub-lane-1` resolves by prefix to agenthub-lane-12 once lane 1
  is gone, so killing a finished lane could kill another.
- getRun/killRun refuse ids that are not `agenthub-lane-<n>`, so
  DELETE /api/run/main can no longer kill a user's own tmux session.
- router/apply reports restart_required only for a running pane; getRun
  returns a `gone` handle, so it was always true.
- needs_action is cleared only by the session that raised it (new nullable
  lanes.needs_action_session); a second session in the same cwd used to
  clear it on its second hook.
2026-09-25 11:30:02 +07:00
nntrivi2001 04b6b98d97 fix(git,macos,runs): close the review findings on #22, #34 and #38
- validateRemoteUrl checks the literal scheme prefix git will receive and
  refuses %/backslash in the authority (WHATWG normalises https:/host and
  percent-escapes that git reads differently); route test for EBADURL.
- Parent-session scrub also drops AI_AGENT, and GIT_EDITOR when it is
  Claude Code's `true`.
- canonicalCwd resolves a not-yet-existing path through its deepest
  existing ancestor, so a symlinked parent still yields one lane.
- Note the clock-step ceiling on liveServers' boot-time filter.
- Docs/header wording: the Active runs count is lane-scoped.
2026-09-25 11:26:31 +07:00
nntrivi2001 478418f602 fix(macos): scrub parent-session env, survive node upgrades and reboots, store lanes by real path (#22)
What broke (code audit, untested on a Mac):
- Without systemd the server is always spawned from inside a Claude Code
  session, so it inherited CLAUDECODE, CLAUDE_CODE_SESSION_ID,
  CLAUDE_PLUGIN_ROOT, ... and tmux handed them to every lane pane (a codex
  pane even resolved as `claude` in `agenthub`'s harness check).
- The ~/.local/bin/agenthub launcher pinned a versioned node path that
  `brew upgrade`/nvm deletes, leaving the CLI dead until the next bootstrap.
- A discovery entry left over from before a reboot could name a recycled pid,
  so the bootstrap's fast path believed a dashboard was live and
  stopDashboard could signal an unrelated process.
- Lanes were stored under the typed cwd; macOS reports /private/tmp and the
  on-disk case, so sessions never bound to a /tmp or differently-cased lane.

What changed:
- server/index.js deletes a DENYLIST of parent-session variables at startup;
  user config (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) stays.
- linkCli's launcher falls back to `node` on PATH when the pinned one is gone.
- liveServers ignores entries whose startedAt predates this boot (60 s margin).
- createLane and POST /api/lanes/ensure store/look up fs.realpathSync.native
  of an existing cwd; the liveness reaper also tries the realpath. Existing
  rows are untouched.
- session-liveness gains an exec seam so the darwin ps+lsof branch is tested.
- plugin-doctor names ~/.zshrc on darwin; INSTALL.md gains a macOS section
  with the Mac verification checklist, mirrored in both READMEs and PLUGINS.md.
No launchd, no macOptionIsMeta, engines stays >=20.
2026-09-25 11:23:59 +07:00
nntrivi2001 aff79a787c feat(lanes,git): publish a repo with no remote, and read a repo with no commits
What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.

What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
  `head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
  subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
  `push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
  all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
  `validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
  local paths, file://, ext::, fd::, git://, http://, leading `-`:
  EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
  commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
  no remote offers Publish… with an inline URL box; a remote but no
  upstream relabels Push as "Publish branch". AddLaneModal disables Add
  on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
  now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
  docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
2026-09-25 11:21:30 +07:00
nntrivi2001 f514602697 feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
2026-09-25 10:44:40 +07:00
nntrivi2001 605ca9d65e chore(release): bump version to 1.8.8 2026-09-24 17:08:18 +07:00
nntrivi2001 158783d5d5 feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing
- Changes panel at full screen: the whole Files view (finder, expand-all,
  tree) becomes a left sidebar and the Files tab is dropped; a working-copy
  file opens straight into the editor (Save/Cancel live once the text
  differs, Cancel restores it, Whole file toggles back to the diff), and the
  page is height-locked so the editor no longer collapses with no live run.
- Overview splits into Staged Changes / Changes groups in the work range.
- A changed file's whole tree row takes its status tint.
- Drag an image onto the lane terminal: POST /api/run/drop-image saves it
  (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path
  is pasted into the pane, which is how claude attaches it.
- Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40);
  claude-opus-5% had been pricing it as Opus 5.
- server/lib/pricing-sync.js: a model family used in token_usage with no
  row of its own is inserted from LiteLLM's public price list, never
  updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly
  that one third-party GitHub raw URL.
2026-09-24 17:05:15 +07:00
nntrivi2001 804b193ac7 chore(release): bump version to 1.8.7 2026-09-24 15:54:09 +07:00
nntrivi2001 d397d3a1f6 feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux
  session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the
  lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's
  Active runs hides what the other panes show; stop/reset/remove kill
  every slot. No per-pane lane picker any more.
- Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js)
  with the read path's guards, existing text files only, and a baseSha
  stale check (409 ESTALE). A file it cannot take says why.
- Per-hunk review bar under each hunk's last changed line: prev/next,
  "n of N", Undo, Keep. Review diffs no longer nest their own scroll box
  (unless windowed), so navigation moves the one real scroller.
- Files tree lists untracked-not-ignored files and colours entries by git
  status (M/U/A/D/R), folders holding changes get a dot.
- Changes panel: Unstage all on the single commit/Stage-all row; sizes
  Narrow (30%) / Medium / Full screen, no 70/30.
- Lane card: the needs-you banner is gone (the strip tile keeps it).
2026-09-24 15:49:39 +07:00
nntrivi2001 67210bcd61 fix(test): run the server suite on a private tmux server
Several suites start real `agenthub-lane-<id>` tmux sessions and then kill
or reset them, with lane ids from their own throwaway database. On the
user's default tmux server those names collide with the dashboard's real
lane panes, so a test run — including the one the pre-commit hook runs on
every commit — killed whichever live session shared an id, the pane the
commit was made from included.

`npm run test:server` now goes through scripts/test-server.js, which drops
TMUX, points TMUX_TMPDIR at a fresh directory and kills that server after.
Also fixes the ERUNTIMEOUT test's tmux mock, which had the wrong (async,
cmd+args) signature and only passed by accident.
2026-09-24 15:48:19 +07:00
nntrivi2001 4e0b869dd3 chore(release): bump version to 1.8.6 2026-09-24 14:40:32 +07:00
nntrivi2001 cfe832383c feat(lanes): stage/undo one hunk at a time, sticky review file headers
Review and the single-file view gain per-hunk Stage and Undo
(stage-hunk / discard-hunk: the hunk is named by its @@ line and
re-matched against a fresh diff, then git apply --cached / -R; undo
needs confirm: true and two clicks). Each review file header stays
pinned while its diff scrolls and carries the file's own
stage/unstage/discard.
2026-09-24 14:40:31 +07:00
nntrivi2001 0737786faf feat(lanes): full-width Changes, Stage all, one-read expand-all
- The Changes panel's width control gains a full-width step (narrow →
  50/50 → 70/30 → full → closed); the console beside it is hidden with
  CSS, never unmounted.
- "Expand all folders" is one GET /api/lanes/:id/git/tree-all (one
  `git ls-files`, every level shaped like /git/tree) instead of one
  request and one git process per folder.
- Overview: a Stage all button and the staged-only commit box sit above
  the file list and stay pinned while it scrolls; the Files finder is
  pinned too.
2026-09-24 14:40:31 +07:00
nntrivi2001 428519335d fix(run,lanes): prompt autocomplete, per-lane Active runs, compact Changes header
- The run form's prompt box offers suggestions again: `/` lists the run
  cwd's project and user commands and skills plus the CLI built-ins
  (claude lanes only), `@` lists files under the cwd; Enter picks while
  the list is open and sends otherwise. The 2f39f4e rewrite had left a
  bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
  (sessions matched to the lane by longest path-boundary cwd), instead of
  every lane's.
- The Changes panel's git line and its actions are one compact row in one
  card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
  leaving an empty frame where the git row used to be.
2026-09-24 13:32:43 +07:00
nntrivi2001 faa56f5c75 feat(lanes): move the lane's git section into the Changes panel
- The branch line, the uncommitted-files hover panel and the git action
  row (fetch/pull/push/branch) move from the lane card to the top of the
  Changes panel (new LaneGitHeader.tsx); the card has no git row now.
- Header and panel share one git-facts read: stage/unstage/discard/commit
  and Refresh re-read it, and a git bar action re-reads the diff, the
  history and the file tree.
- The git bar no longer commits: the panel's staged-only box is the one
  commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi).
- "New lane here" is wired from Workspace to the panel.
2026-09-24 11:44:31 +07:00
nntrivi2001 7bc4594b94 fix(lanes,git): harden the lane git surface and fix client races in the Changes panel
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js):
- Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a
  file name is a file name, never a glob, and odd names parse correctly.
- A lane that is a subdirectory of its repo sees and acts on ONLY its own
  paths (reads, stage/unstage/discard/commit, and gitFacts.files).
- Any file named settings.local.json (the lane's router API key) is refused as
  403 ESECRET on disk (symlinks resolved) and at every committed rev.
- commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is
  staged (checked with --no-renames, so a move into the lane is caught).
- push resolves its remote (upstream → only remote → origin), else 400
  ENOREMOTE; unstaging a rename unstages both halves.
- Git writes serialize on the repo's git dir, not the lane id, so two lanes on
  one index cannot collide on index.lock.
- git refusing an action (a numeric exit status) answers 409 EGIT with git's
  stderr instead of a 500 carrying a numeric code; one shared parseNumstat.

Client:
- Changes panel: no stale answer from a previous lane or an older quick-open
  query repaints the panel; discard disarms on lane switch; Enter on a file
  path opens it instead of folding; bounded tree expand-all.
- Large diffs (> 500 rows) render only the visible rows, with stable width
  and in-sync split columns; mirrored split scroll is clamped.
- Git bar: no "in sync" without counts, a clear no-remote message, branch list
  re-read after a switch, and no previous-lane facts after a lane switch.
- Tooltips no longer flicker or close on unrelated scrolls.
2026-09-24 10:30:25 +07:00
nntrivi2001 6153d50fa8 chore(release): bump version to 1.8.5 2026-09-24 10:30:25 +07:00
nntrivi2001 562f43fd88 fix(ui): keep the sidebar's EN/VI buttons at full width beside the three theme buttons
The language row was a 4-column grid holding two buttons inside a flex-1 box. Once the theme row gained a third (System) button, that box narrowed and each language button fell below the width of its own label, so EN/VI sat off-centre and overflowed. One column per language, centred text.
2026-09-24 10:28:51 +07:00
nntrivi2001 8ea40260ad feat(ui): terminal, charts and low-contrast text follow the selected theme
- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
2026-09-24 09:50:44 +07:00
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
nntrivi2001 b0e8368c1d chore(release): bump version to 1.6.9 2026-09-17 16:16:39 +07:00
nntrivi2001 686b80bd99 fix(lanes,reload): count a pipeline run by declarations, and stop the two reload commands merging
Closes #26, #25.

`runs` is the denominator every pass rate on the Workflows page is read
against, and any backwards move opened a new one. Stage detection bounces by
design — one feature reads `implement → tests → implement` as the session
edits, runs the suite and edits again — so a single feature was recorded as
five or six runs, and a lane in constant use reported a handful of passes
spread across dozens of phantom runs. A backwards move now opens a run only
when it is DECLARED; a backwards detection continues the run in flight.
Reaching the final node still closes the run for either, since work after
`done` is new work however it was observed. Replayed over this repo's own lane
the rule takes 26 recorded runs down to the 5 features that actually shipped
through it. The panel itself is unchanged: passes are still declarations only.

`/reload-plugins` and `/reload-skills` were typed back to back. Claude Code
opens its slash-command menu the moment `/` is typed and the Enter that follows
lands on that menu, so the second command was appended to the line still in the
composer and the pane submitted `/reload-plugins/reload-skills` — which Claude
Code runs as `/reload-plugins` with an argument, never reloading skills, and
leaving a stray prompt turn in the scrollback. Reproduced and fixed against a
live pane: SETTLE_MS (1s) between the two sends, which makes
`reloadOpenSessions` async rather than block the event loop, and the same pause
in the detached update-all worker.
2026-09-17 16:16:31 +07:00
nntrivi2001 02ee46c862 chore(release): bump version to 1.6.8 2026-09-17 15:09:30 +07:00
nntrivi2001 5bd1c0323f feat(lanes): reorder and rename lane cards, and show each file's line delta
The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.

Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.

That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.

The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
2026-09-17 15:07:37 +07:00
nntrivi2001 b5ca531800 chore(release): bump version to 1.6.7 2026-09-17 13:21:34 +07:00
nntrivi2001 0e38d42cf9 refactor(workspace): drop the page header, move Add lane and Tools onto the lanes
The title and the four counters said a second time what the lane strip
already shows lane by lane (liveness dot, ⚠ marker), so they only cost a row
of height on a screen whose point is the terminal. `GET /api/lanes` still
returns `counts` for other callers.

Add lane becomes the strip's FIRST tile — creating a lane is the same kind of
act as picking one — and the per-lane Tools menu (agents install / mcp sync /
sync-base check) moves onto the lane-detail header row beside the pipeline
picker and the Model select, next to the lane it acts on.
2026-09-17 13:20:14 +07:00
nntrivi2001 af2ad63a18 feat(lanes): name the files behind a lane's uncommitted counts
GET /api/lanes/:id/git now returns `files`, the raw `git status --porcelain`
lines the dirty/untracked counts were already parsed from, capped at 20 so a
lane mid-`npm install` cannot ship thousands of paths through a polled
endpoint. The lane card hovers them as the tooltip on its
"N modified · N untracked" text, appending "… +N" when the cap bit.

No new subprocess and no new endpoint — statusCounts was counting those lines
and throwing them away.
2026-09-17 13:19:07 +07:00
nntrivi2001 2fe1dcd46f chore(release): bump version to 1.6.6 2026-09-17 11:39:00 +07:00
nntrivi2001 7fae2f48c0 refactor(run): drop the lane progress bar, iconify pane layout, and one-row the run setup
Four more spots that spent height saying what was already visible or
saying too much of it.

**LaneCard** drops the progress bar and percent entirely — the pipeline
map right below the card already draws the same number as named,
coloured nodes, and a bar beside it was a worse second telling of it.

**SplitLayoutToggle** (Workspace) swaps "1 pane" / "2 pane" / "4 pane"
text buttons for the conventional square / two-column / 2x2-grid icons
(`Square`, `Columns2`, `Grid2x2`) most apps use for this. The words move
to `aria-label` and `title` rather than disappearing.

**RunSetup** merges the fresh/resume source picker onto the same row as
the Working directory / Model / Permission mode / Thinking effort fields
instead of a second row above them — settings left, New/Resume session
right. The session picker for an active resume still gets its own row
below, since it only appears in that one state.

**LaneConsolePane**'s "Run Claude" header goes from a two-row title +
wrapped blurb to one row (smaller icon and title), and the blurb itself
is trimmed to one short sentence, hidden below `sm` where it would wrap
again anyway.

`docs/LANES.md` updated to match (split-view description, lane-card
field list, and `agenthub stage`'s progress-bar wording).

Verification: `tsc --noEmit` clean, `npx vitest run` 508/508 (2 stale
progress-bar assertions in LaneCard.test.tsx replaced with one asserting
the bar is gone), `npm run build` clean, screens.snapshot diff reviewed
before regenerating (icon buttons + new blurb + reordered DOM only), file
headers audit clean.
2026-09-17 11:37:01 +07:00
nntrivi2001 229002cc2b chore(release): bump version to 1.6.5 2026-09-17 10:02:12 +07:00
nntrivi2001 27923db4e0 refactor(lanes): compact the lane strip tile and the lane-detail header
The Workspace lane panel spent four rows saying what fits in two.

Strip tile: the stage badges move onto the id row and the progress bar
and percent go away — the detail panel's pipeline map right below shows
the same progress with the stage names attached.

Lane detail: the pipeline picker, stage badges, feature picker, router
select and the remove button merge into the single header row instead of
a second row under it, and the pipeline's name is no longer interpolated
into `laneHeader` — the picker beside it already says it. The git block
collapses from four lines to one and drops the cwd, which the header
above it already spells out.

The router select is relabelled Model, moves out of LaneCard into that
header row (`LaneRouterRow` is now exported), and reports its apply
outcome as a ✓/⚠ marker with the message in a tooltip rather than a
wrapped sentence that pushes every row below it down.

Drops the tracker / dev QC / CI wait badge row and everything feeding it
(`useLaneIntegrations`, `api.lanes.integration`, the `integrations.*`
strings): two of the three toggles have no consumer at all, and a badge
reporting a toggle nothing acts on costs a row of height to say nothing.
`agenthub lanes integration` and the API route are untouched.
2026-09-17 09:41:03 +07:00
nntrivi2001 5ab674f134 chore(release): bump version to 1.6.4 2026-09-16 11:28:18 +07:00
nntrivi2001 00a2aedb09 feat(ai-router): test an endpoint before saving it, and re-point a live lane
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.

**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.

The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.

Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.

**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.

Adds `Lane.router_profile` to the client type (the payload already carried it).
2026-09-16 11:26:00 +07:00
nntrivi2001 9128352d1b fix(pricing): price Claude Opus 5, correct Sonnet 5, drop retired models
Claude Opus 5 had no pricing row at all, so every token it burned was costed
at $0 — the pattern list stopped at the 4.x Opus family and nothing matched
`claude-opus-5`. Add it (5/25, fast mode 10/50, same shape as 4.8).

Sonnet 5's launch discount expired 2026-08-31 and the discounted rate became
the published standard, so the row's 3/15 standard rate started overcharging
the day after. Fold 2/10 into DEFAULT_PRICING and empty DEFAULT_INTRO_PRICING;
no model ships a promo today.

Fable/Mythos 5.1 fell through to the `claude-fable-5%` row, which caches at
$1/MTok against 5.1's $0.25 — give them their own rows. That overlap is only
resolvable if the lookup prefers the more specific pattern, and `matchPricing`
took `LIMIT 1` with no ORDER BY, i.e. whichever row was inserted first. Order
by pattern length descending, which is what docs/DATABASE.md already claimed.
(The nested REPLACE(model_pattern, '%', '%') it dropped was a no-op.)

Retire Claude 3.x, Opus 4/4.1 and Sonnet 4 from the defaults. The startup
top-up only ever ADDS missing rows, so removing them from DEFAULT_PRICING
would leave every existing install untouched — prune them once, guarded by
PRAGMA user_version (0 -> 1) so a model deliberately re-added in Settings
survives later restarts. The same one-shot migration corrects the existing
Sonnet 5 row. Only rates are deleted; token_usage is never touched, and
Reset Defaults still restores the full current set.
2026-09-15 15:17:55 +07:00
nntrivi2001 fb3796f064 chore(release): bump version to 1.6.3 2026-09-15 14:55:23 +07:00
nntrivi2001 c3f8dec104 refactor(lanes): drop the tick box and radio dots from the Add-lane modal
Both controls already render as cards that light up when selected — the same
selected/unselected treatment LaneStripCard uses — so the tick box and the two
radio dots were a second indicator of a state the card was already showing.

Neither input is removed, only hidden (`sr-only`): they are still what a
keyboard toggles and what a screen reader announces, so the cards take a
`focus-within` ring in exchange for the focus ring that went with them.

The router radios needed one thing the checkbox did not. "AI Router" is
disabled until the profile list lands, and the greyed-out dot was the only
thing showing that; hidden, it would have read as a card you can click that
does nothing — the dead control the existing comment warns about. So disabled
is now its own branch: muted text, reduced opacity, no hover, not-allowed
cursor.

Layout follows: no flex row with a gap when there is one child left, and the
two equal-width router cards centre their label rather than leaving it hard
against an empty left edge.

Visual only. No behaviour, API or schema change.
2026-09-15 14:53:00 +07:00
nntrivi2001 6e685ce1c3 chore(release): bump version to 1.6.2 2026-09-15 11:01:07 +07:00
nntrivi2001 29a5da3e4e fix(bootstrap): stop a retired plugin build from downgrading the dashboard
`claude plugin update` unpacks the new version into a new cache directory and
says "Restart to apply changes". A Claude Code process that was already running
does not: it keeps loading the OLD directory, hooks and all.

Every session such a process opened then ran the OLD plugin-bootstrap, which saw
a state.json naming a different root, concluded the server had to be replaced,
repointed agenthub.service at itself and restarted the dashboard onto the
version the user had just upgraded away from. The dashboard reported the old
version and offered the update again — once per session, forever, while
installed_plugins.json and the cache both said the new version was installed.
A /agenthub-update from that session "succeeded" and changed nothing.

- `supersededBy()` reads Claude Code's installed_plugins.json. When a SIBLING
  cache directory is the recorded install, the bootstrap logs one line and
  returns "superseded" — no deps, no unit, no server. `--force` does not
  override it: the self-update chain forces from the NEW directory, which is
  never superseded, so the only --force refused is a deliberate downgrade.
  It cannot hand over either — the process it would replace is the one loading
  it — so the log line says to restart Claude Code.
- Matched by sibling directory, not by the <plugin>@<marketplace> key: correct
  without parsing a cache path or trusting a name. A phantom entry (installPath
  that no longer exists) is ignored rather than wedging the bootstrap shut.

Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy
of the repo — node_modules and all — per release; twelve of them measured 1.7 GB
on one machine. It runs after writeState, so a bad prune cannot cost the record
of a good bootstrap, and it protects four independent readings of "in use":
this build, every installPath Claude Code records, the directory
agenthub.service names (`unitRoot`, foreign unit included), and any directory
still named in the user's settings.json hooks — that last one is the un-restarted
process above, seen from the other side. Plus the 2 newest survivors as a
rollback cushion, and nothing at all outside ~/.claude/plugins/cache/.
2026-09-15 10:30:00 +07:00
nntrivi2001 713e9fe58b chore(release): bump version to 1.6.1 2026-09-14 16:33:46 +07:00
nntrivi2001 13bc7154d6 fix(updates): keep the update modal's footer buttons on one line
The footer wrapped every label mid-word ("Check / now", "Copy / command")
because nothing stopped the text breaking and the modal was too narrow to
hold four buttons.

Three changes, smallest first:

  - `whitespace-nowrap` on the footer container. `white-space` inherits, so
    one class keeps every label on its own line without touching each button.
  - `flex-wrap` alongside it, so a translation that still outgrows the modal
    breaks the ROW rather than the words.
  - `max-w-lg` -> `max-w-2xl` on the dialog.

Those alone were not enough in Vietnamese: the button's restarting label is
a full sentence ("Đã build xong — đang khởi động lại bảng điều khiển...",
52 chars), which put the row over the width on its own. That sentence is
already shown in the status banner directly above the footer, so the button
now uses a new short `restartingShort` key and the banner keeps the full
one unchanged.
2026-09-14 16:32:15 +07:00
nntrivi2001 e895c0e8eb chore(release): bump version to 1.6.0 2026-09-14 16:07:05 +07:00
nntrivi2001 0a86dfb8c5 feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.

The two CLIs are configured differently, and the difference is theirs:

  - a claude lane gets eight `env` keys merged into its own
    `.claude/settings.local.json` (`.local` because the value holds a key);
  - a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
    `codex -p <name>` loads — because Codex has NO project-level config.
    Its base `config.toml` is never touched.

Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).

Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.

Three things this had to get right, each found by review and verified
against the real tools rather than assumed:

  - `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
    supported"), so every codex lane on a router would have failed to start.
    The layer writes "responses", and writes `env_key` only when the profile
    actually has a key — naming an unset variable is itself a startup error.
  - The key must never be TYPED into a pane. `tmux.sendCommand` used
    `send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
    every attached browser and bash appends to history; it now takes over the
    idle pane with `respawn-pane -k -e`, passing env through argv the way
    `newSession` always has. Verified on tmux 3.4: multi-element argv arrives
    intact and the pane renders no trace of the value.
  - Attaching COPIES a profile, so editing or deleting one in Settings has to
    reach the lanes already using it. An edit re-applies it (a rotated key
    would otherwise never reach the lane still holding the revoked one); a
    delete resets those lanes to native and removes the Codex layer file.
    Both report per-lane failures, which the tab surfaces.

`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.

DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
2026-09-14 16:04:57 +07:00
nntrivi2001 fc7b58f15b chore(release): bump version to 1.5.10 2026-09-14 13:10:21 +07:00
nntrivi2001 c4025cd2f7 style(lanes): render the create-readme option as a selectable card
Matches LaneStripCard's selected/unselected treatment and moves the hint
inside the card. Drops the "codex has no slash commands" clause from the
hint — the option is already hidden for a codex lane.
2026-09-14 13:10:16 +07:00
nntrivi2001 ee1e07bb5e fix(update-check): resolve the release URL for a relative plugin source
This project's marketplace entry is `"source": "./"` — the plugin IS the
marketplace repo — but pluginMarketplaceUrl only accepted a URL string or
`{url}`, so it returned null and every plugin-cache install answered
`no_remote`: no install of this plugin could ever see a published release.

A relative source now falls back to the manifest's own `repository`. An
`owner/repo` shorthand still returns null — it names a different repo, so
borrowing the marketplace's URL would be a confidently wrong answer.
2026-09-14 13:10:16 +07:00
nntrivi2001 91af3dca86 chore(release): bump version to 1.5.9 2026-09-14 11:22:11 +07:00
nntrivi2001 baa355bee4 feat(lanes): offer a create/update readme run when adding a lane
An optional checkbox in the Add-lane modal 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 or refreshing its CLAUDE.md/AGENTS.md set.
Claude lanes only — codex has no slash commands.
2026-09-14 11:20:37 +07:00
nntrivi2001 b7e81af0b6 chore(release): bump version to 1.5.8 2026-09-11 16:48:00 +07:00
nntrivi2001 c90dd7b147 fix(bootstrap): self-heal a systemd unit left on a retired plugin root
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.

- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
  instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
  path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
  server keeps running and the corrected unit takes effect at the next
  start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
  detached-spawn fallback is guarded by needsServer so a stale unit alone
  cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
  stopped owns nothing: skipping the stop there and asking systemd to start
  would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
  come back after a reboot") and the fix. WARN, never FAIL — the dashboard
  in front of you works, and having no unit at all is a normal setup.

The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
2026-09-11 16:45:47 +07:00
nntrivi2001 49a041b1dc chore(release): bump version to 1.5.7 2026-09-11 16:32:50 +07:00
nntrivi2001 de8d1079e0 fix(bootstrap): never stop the dashboard from inside its own systemd cgroup
A plugin self-update took the dashboard down and left it down. The chain
ran correctly up to the point where the bootstrap SIGTERMs the server it is
replacing — and that server was systemd-managed, with the bootstrap running
as its descendant, i.e. inside its cgroup. Two failures fired at once:

  * the server exits 0, and `Restart=on-failure` reads a clean exit as
    "meant to stop", so systemd did not bring it back;
  * `KillMode=control-group` reaped the bootstrap itself, mid-run, before it
    could repoint the unit at the new plugin root or write state.json.

Evidence from the incident: bootstrap.log ends at "stopped the server
started from the previous plugin version" with no systemd line after it,
.bootstrap.lock survived with a dead pid, state.json still named the
previous version, and the unit's ExecStart still pointed at the old cache
dir. The same truncation is visible on three earlier updates — those only
recovered because a later SessionStart bootstrapped again.

systemd-service.js now splits install into writeUserService (repoints the
unit, starts nothing) and restartUserService (--no-block). The bootstrap
skips the manual stop whenever a unit of ours owns the server, and defers
the restart past writeState and releaseLock, so being killed by its own
request costs nothing. The unit is Restart=always: a graceful SIGTERM from
anything must no longer read as "leave it stopped".

installUserService keeps its old semantics for `agenthub service install`,
which runs from a user's shell and is not in the cgroup.
2026-09-11 16:30:10 +07:00
nntrivi2001 0335e0e6bc chore(release): bump version to 1.5.6 2026-09-11 15:59:33 +07:00
nntrivi2001 18614f378d fix(updates): give a plugin-cache install a real message, not null
`agenthub update-check` prints `message` as its headline, so every
plugin-cache install saw the literal "null" where the verdict belongs — and
that is the one install kind with no commit count to fall back on, so the
line carried nothing at all.

The plugin branch of getUpdatesStatus compares versions exactly like the
checkout branch does; it now builds the same four sentences from what it
already computed (newer release / newest / no local version to compare /
release lookup failed).

The nogit regression test asserted `message === null` as a proxy for "did
not fall through to the not-a-git-clone payload" — it now asserts that on
the message's content instead, which is what it always meant.
2026-09-11 15:54:03 +07:00
nntrivi2001 63ed90ba05 docs(plugins): stop promising an MCP server the plugin no longer ships
c7dbaf2 dropped it. The doctor row list also claimed an 'MCP build
freshness' check that plugin-doctor.js does not emit.
2026-09-11 15:39:24 +07:00
nntrivi2001 3ed88062d6 chore(release): bump version to 1.5.5 2026-09-11 15:35:35 +07:00
nntrivi2001 27313d4413 fix(run): refuse a run with ETMUXMISSING when the host has no tmux
Starting a run on a machine without tmux surfaced `spawnSync tmux ENOENT`
as a 400 — blaming the request for a missing host package, naming no fix,
and reaching the user as a raw errno in the console pane.

The guard goes in spawnRun, which both spawn paths (POST /api/run and
POST /api/lanes/:id/start) already route through, so one check covers
them; the message carries the install command. Both routes map the code to
503: the request was fine, the dependency is not installed. The client
already renders `error.message` from the body verbatim, so no UI change.

Also: README's node advice said "stay on 20/22/24"; 1712 server + 488
client tests are green on v26.8.1, so only 25 is actually the bad one.
2026-09-11 15:32:24 +07:00
nntrivi2001 d4b949a6f4 docs(deployment): list git and tmux beside node in the prerequisites
The only prerequisites block that still said node+npm alone.
2026-09-11 15:22:11 +07:00
nntrivi2001 8930865376 test(cli): cover the doctor's tmux line
The row was added without a check here, so a regression that dropped it
would only surface when someone's Start Run failed silently again.
2026-09-11 15:20:30 +07:00
nntrivi2001 29858ddccb docs: document the system prerequisites, and report tmux in both doctors
The READMEs never said tmux was needed, so a fresh install only found out
when Start Run failed with nothing explaining why. Both doctors were silent
about it too.

- README/README.vi: a prerequisites table (node, npm, git, tmux, claude,
  codex, gh, systemd) and per-OS tmux install + verify steps; the feature
  list is regrouped into observe/drive/configure and now covers analytics,
  alerts, import, remote sources, the config explorer and Update all.
- INSTALL: Step 0 for the system packages, plus a troubleshooting entry
  separating "tmux missing" from "the server has a different PATH".
- SETUP/docs/README: the same troubleshooting entry and a pointer to Step 0.
- plugin-doctor: a tmux row, WARN and never FAIL - the run console and
  `lanes shell` break without it, nothing else does. Reuses tmux.js's own
  isTmuxAvailable().
- agenthub doctor: asks GET /api/run/tmux instead, because the PATH that
  decides whether a run starts is the server's, not the caller's shell's.
2026-09-11 10:44:10 +07:00
nntrivi2001 7000a9c5ce chore(release): bump version to 1.5.4 2026-09-10 23:21:45 +07:00
nntrivi2001 99e718ad91 fix(plugin): build the UI with devDependencies so a self-update ships its own bundle
After the 1.5.2 -> 1.5.3 self-update the server ran 1.5.3 while the dashboard
still served the 1.5.2 UI. The client build had failed, and `runtime/client-dist`
still held the previous version's bundle, so nothing looked broken — the only
signal was the Settings page's "UI build v1.5.2 (rebuild client to match)".

Cause: the whole client build toolchain (vite, typescript,
@vitejs/plugin-react, tailwindcss, @types/*) is a devDependency, and npm reads
NODE_ENV=production as --omit=dev (`NODE_ENV=production npm config get omit` ->
`dev`). The dashboard server runs with NODE_ENV=production, and every bootstrap
descended from it inherits that: the plugin-update chain, and any SessionStart
fired by a `claude` running in a tmux pane the server started. So `npm ci`
dropped vite, `tsc -b` died on missing type declarations, and buildClient
returned "failed" with the stale bundle left in place.

`--include=dev` on both install paths — a CLI flag outranks the env, which is
the point. Verified by running buildClient with NODE_ENV=production against the
installed plugin root: previously "failed", now "built", and the served bundle
carries 1.5.3.

Also: plugin-update-helper writes its verdict BEFORE the bootstrap handover as
well as after. That step retires the process tree the helper hangs off, and the
observed run reached the new server with no verdict file at all — the dashboard
reported `last_plugin_update: null` for an update that had actually succeeded.
2026-09-10 23:17:33 +07:00
nntrivi2001 67c948bbe2 chore(release): bump version to 1.5.3 2026-09-10 17:09:07 +07:00
nntrivi2001 8b8582f231 fix(runs): persist tmux-backed runs so a resumed session shows its own info
`dashboard_runs.mode` is NOT NULL and `recordRun` swallows insert errors by
design, so pty-run.js passing `mode: null` meant NO tmux-backed run was ever
persisted — the newest row in a live database dated from before pty-run.js
replaced run-spawner.js. `publicRun` reads cwd, model, prompt preview,
started_at and session_id off that row and the handle itself is recomputed
from tmux, so the runs modal rendered a live row with no cwd, no model, no
prompt and a start date of Jan 1 1970. The same missing row is why a resumed
session kept its separate `external` badge: the client dedupes an external
session against the session ids carried by run rows, and no run row ever
carried one.

- pty-run.js records `mode: "interactive"` — every run here is one pane.
- dashboard-runs.js still never fails a live run on a db error, but warns
  instead of swallowing silently; that silence hid this for six weeks.
- RunHistory renders `startedAt: 0` as "-" rather than dating a row with no
  known start time to the epoch.

Also closes the other half of the same lie: adopting a pane that is already
running an agent now throws ERUNLIVE (409 from both the lane start action and
POST /api/run) when the request carried a prompt or a resumeSessionId. That
argv cannot be typed over a live agent, so answering 200 reported a resume
that never spawned. A start asking for nothing but a pane still adopts.
2026-09-10 17:07:23 +07:00
nntrivi2001 b868a2fa85 chore(release): bump version to 1.5.2 2026-09-10 16:47:16 +07:00
nntrivi2001 30bc6debde fix(updates): check releases for a plugin install with no .git
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.

The plugin branch now runs BEFORE the .git probe and answers without git at
all: the repository URL comes from the marketplace manifest on disk
(known_marketplaces.json -> .claude-plugin/marketplace.json -> source.url,
null rather than a guessed host for a github owner/repo shorthand), and
package.json is the only local comparison signal. A failed release lookup
is reported as release_check, never as "nothing newer".

UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
2026-09-10 16:46:06 +07:00
nntrivi2001 d4052ed235 chore(release): bump version to 1.5.1
openapi.yaml embeds info.version and the Settings screen snapshot pins the UI build string, so both are regenerated in the same commit - repo-links.test.js and the snapshot test each fail otherwise.
2026-09-10 16:16:47 +07:00
nntrivi2001 aca35dd4cd feat(cc-config): update every plugin and marketplace at once, then reload the open sessions
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect.

Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement.

readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race.

Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: 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 - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command.

Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action.

Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted.

Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
2026-09-10 16:05:07 +07:00
nntrivi2001 cc85e35439 feat(updates): detect the newest release and self-apply it, including plugin installs
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look".

Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless.

POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process.

The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard.

Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist.

Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
2026-09-10 16:03:07 +07:00
nntrivi2001 eb63569324 Merge pull request 'chore: remove unused surfaces (VS Code extension, statusline, landing assets, Docker)' (#14) from chore/remove-unused-surfaces into main 2026-09-10 07:12:08 +00:00
nntrivi2001 a146284087 Merge pull request 'fix: make every plugin in the marketplace actually installable, and repoint the repo-rename leftovers' (#13) from fix/plugin-install-and-repo-rename into main 2026-09-10 07:11:45 +00:00
nntrivi2001 61cc37ba32 chore: remove unused surfaces (VS Code extension, statusline, landing assets, Docker)
The repo shipped five install/publish surfaces that nothing in the project builds, tests or documents as supported any more. Each one carried its own stale docs, and the Docker/Podman path additionally described a Dockerfile whose COPY of statusline/ would have broken the build the moment that directory went away.

Removed: vscode-extension/ (16 files, marketplace extension for an ID we do not own); statusline/ (3 files, standalone CLI statusline); the repo-root landing/wiki PWA assets (sw.js, manifest.json, robots.txt, sitemap.xml, og-image.svg, favicon.svg, fonts/ - 17 files for pages that no longer exist); .superpowers/ (4 files of scratch brainstorm state, already covered by .gitignore:55); and the container publish path (Dockerfile, docker-compose.yml, .dockerignore, 5 Makefile targets, 2 npm scripts).

Kept deliberately - these are about the USER's containers, not ours: scripts/install-hooks.js container detection and AGENTHUB_ALLOW_CONTAINER_HOOKS (issue #193), server/lib/lane-detect.js docker-compose service detection, server/data/profile-templates/postgres-compose/, server/lib/stage-detect.js 'docker push' detection, the host.docker.internal Prometheus notes, the Config Explorer's reads of the user's own ~/.claude/statusline.py, and .devcontainer/ (its docker-in-docker feature is dropped since it existed only to build the removed Dockerfile).

Docs follow the code: ARCHITECTURE.md loses the VS Code, statusline and container sections plus two dead TOC anchors, and its PWA section is rewritten from three PWAs to one; INSTALL.md, SETUP.md, docs/DEPLOYMENT.md, server/README.md, client/README.md, docs/README.md, docs/HOOKS.md and docs/DATABASE.md drop the same. The Makefile target count in ARCHITECTURE.md is corrected 25 -> 17 (it was already stale).
2026-09-10 13:44:28 +07:00
nntrivi2001 e972267a42 fix(plugins): make the 10 focused plugins installable, finish the AgentHub rename
The ten `agenthub-*` plugins were listed by the marketplace but could never be
installed — two schema mistakes present since the entries were first authored
(57dc915), both invisible until you actually run `claude plugin install`:

- marketplace entries used `"path": "plugins/<name>"`; the CLI only accepts
  `"source"`, so every one failed with
  `This plugin's marketplace entry is invalid: source: Invalid input`
- each `plugin.json` carried npm's `"repository": { "type", "url" }` object;
  the schema wants a plain string, so the next attempt failed with
  `Validation errors: repository: Invalid input`

Verified by installing all 11 entries into a throwaway CLAUDE_CONFIG_DIR:
1/11 before, 11/11 after. `plugins-marketplace.test.js` was asserting the
broken shape, which is why CI stayed green — it now asserts `source` and
rejects `path`.

Marketplace renamed `claude-code-agent-monitor-plugins` -> `agenthub`, so the
install line is `agenthub@agenthub`. ARCHITECTURE.md documented a marketplace
name (`smartgift-claude-code-monitor`) that never existed in any revision.

The documented `/plugin marketplace add Smartgift-AI/Claude-Code-Monitor` also
never worked: the `owner/repo` shorthand resolves to github.com only, and this
repo is self-hosted on Gitea. Confirmed the CLI rejects `host/owner/repo` and
ignores GH_HOST / GITHUB_HOST / CLAUDE_CODE_MARKETPLACE_URL, so the full
https:// clone URL is the only supported form. Same GitHub-habit class fixed
elsewhere: raw.githubusercontent.com icon URLs (404, and the repo ships
favicon.svg not favicon.ico — the client had the same dead /favicon.ico),
a smartgift.github.io docs link (404), and Gitea `/blob/<ref>/` paths that
must be `/src/branch/<ref>/`.

Repository renamed to Smartgift-AI/AgentHub throughout. Gitea 301-redirects
the old name, so these were stale rather than broken; `docs/superpowers/`
keeps the old name because those are dated decision records.

Adds `repo-links.test.js` to guard both classes (it runs under the existing
pre-commit gate) plus an openapi.yaml drift check — the committed spec had
silently fallen a minor version behind package.json. `generate-openapi-yaml.js`
now exports `render()` so the test compares against one definition of the dump
options instead of a second copy.
2026-09-10 11:03:14 +07:00
nntrivi2001 876c6de6d2 fix(lanes): count every pipeline run, not one per lane
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.

Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.

Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.

Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
2026-09-09 15:32:09 +07:00
nntrivi2001 3549000fa9 chore(release): bump version to 1.5.0
Minor, not patch: this cycle added the Codex config panel, made Claude
Code's MCP surface editable, and added the project-memory route both
panels use — new surfaces, no breaking change.
2026-09-09 14:46:02 +07:00
nntrivi2001 2942ced3ec style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab
The Codex tab strip wrapped onto a second row while Claude Code's stayed
one scrollable row, so two panels that are meant to be twins read as
different components. Both now render the same ConfigTabBar — the
scroller lifted out of CcConfig, with the fade edges and chevrons that
only appear when there is somewhere to scroll. The deferral note in
CodexConfig that predicted this is gone with it.

The bar also carries role="tablist"/"tab" and aria-selected now: Codex
had them, Claude Code did not, and one of the two had to win.

Cursor's placeholder tab goes away with the machinery that served only
it — the optional Panel, the "soon" badge, the coming-soon card, and
their four i18n keys.

Element.scrollBy and friends move to test-setup: jsdom has none of them,
and the tab bar now calls them from both pages.
2026-09-09 14:44:25 +07:00
nntrivi2001 742e23e418 Merge branch 'feat/claude-settings-editable' into main
Makes the Claude Code MCP surface editable from the dashboard (add and
remove via the claude CLI), corrects which .claude.json the dashboard
reads so it matches the one the CLI writes, and adds the project-memory
route both panels use to create a new project's CLAUDE.md / AGENTS.md.
2026-09-09 13:56:02 +07:00
nntrivi2001 d7c1a10724 Merge branch 'fix/config-path-containment' into main
Closes two pre-existing holes in the Claude Code writer: a symlink at
(or above) a write target escaped the config root, and PUT/DELETE
/file honoured a caller-supplied ?cwd=. Both now go through the shared
guard in config-write.js.
2026-09-09 13:55:57 +07:00
nntrivi2001 e63bbf825e Merge branch 'feat/codex-config-panel' into main
Codex config panel in /settings, at full parity with the Claude Code
panel: skills, agents, rules, AGENTS.md memory and config.toml are
readable and editable, with MCP servers, plugins and marketplaces
driven through the codex CLI.
2026-09-09 13:55:53 +07:00
nntrivi2001 2ade30f670 fix(cc-config): close the symlink write escape and the caller-chosen write target
The Claude writer's containment was lexical only: a symlink at
~/.claude/<type>/<name>, or at any ancestor of it, is a string inside the
root while the write lands wherever it points. The backup destination had
the same exposure, and delete copies through the link before unlinking it.

PUT/DELETE /file also honoured ?cwd=, so a project-scope write could be
aimed at any directory on disk. PATCH /settings already refused that, with
the reasoning in a comment; the two other write routes never adopted it.

The guard moves into config-write.js so both writers run one copy.
2026-09-09 08:30:45 +07:00
nntrivi2001 5720e8d5f8 docs(plans): record the Codex config panel and Claude settings plans 2026-09-09 08:22:17 +07:00
nntrivi2001 85187dfc78 feat(config): create a new project's memory file from Settings
The Memory tab could only show this machine's user memory and the
dashboard's own project file. Setting up a project you had not run
through the dashboard meant opening a terminal — `PUT /file`'s
"project" scope always resolves against the server's cwd, on purpose.

Add one narrow route per tool that writes into the directory you name:
CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same
`project-memory.js`. Being the only write path that honours a
caller-supplied directory, it pays for that:

  - the directory must be absolute and already exist — nothing is
    created for you;
  - the filename comes from the route, never from the body;
  - an existing file, symlink included, is a 409 — this can only
    create, never overwrite;
  - it sits behind the loopback-Origin guard /api/run uses, so a page
    in the user's browser cannot plant agent instructions in a project
    directory. That guard moves to lib/same-origin.js to be shared.

The modal takes the dashboard's known project dirs as suggestions on a
free-text input rather than a closed list: a project it has never seen
is exactly the case this exists for.

Mutation-checked: dropping the origin guard, and swapping lstat for
stat, each fail a test.
2026-09-09 01:45:00 +07:00
nntrivi2001 697384145a test(cc-config): cover the env runClaudePlugin actually spawns with
`autoInstallSkillPlugins` takes `run` as a seam, so every existing test
stops short of the real `spawnSync` — the line that decides whether
CLAUDE_CONFIG_DIR is pinned was untested. Restoring the unconditional
pin here (the bug that sent `.claude.json` to `~/.claude/.claude.json`)
kept the whole suite green.

Export `runClaudePlugin` and drive it against a fake `claude` on PATH
that prints the variable back: one test for CLAUDE_HOME set, one for
unset. Mutation-checked — the unconditional pin fails the second.
2026-09-09 10:16:58 +07:00
nntrivi2001 114823ceef fix(cc-config): stop pinning CLAUDE_CONFIG_DIR in the bootstrap's plugin installer too
skill-plugins.js carried its own copy of the unconditional pin, with the
same comment claiming it keeps writes where cc-discovery reads. Same rule
as runClaude now: pin only when the operator set CLAUDE_HOME.

Also corrects three comments that described the old behaviour — the
confidently-wrong pair at these two call sites is what let two readers
believe reads and writes were aligned when they were not.
2026-09-09 10:09:24 +07:00
nntrivi2001 c34931c3f5 fix(cc-config): .claude.json sits BESIDE ~/.claude by default, not inside it
The previous commit routed the read through getClaudeHome(), which appends
/.claude when CLAUDE_HOME is unset — pointing the dashboard at
~/.claude/.claude.json, a file the CLI never writes by default. Verified
against the real binary: with CLAUDE_CONFIG_DIR unset the CLI writes
$HOME/.claude.json; with it set, .claude.json sits flat inside that dir.
Branch on CLAUDE_HOME accordingly.

The test now drives a fake claude that writes where the real one does and
asserts getClaudeJsonPath() names that exact file, under both branches —
the previous test only restated the implementation.
2026-09-09 10:00:05 +07:00
nntrivi2001 17e4321761 fix(cc-config): read .claude.json from the same root the CLI writes it to
getClaudeJsonPath() was the only one of cc-discovery's 20 read paths not
derived from getClaudeHome(), so a CLAUDE_HOME install read one file while
the CLI wrote another. The default case was fixed by dropping the
CLAUDE_CONFIG_DIR pin; this closes the mirrored half.

Also replaces the Remove button's display-string comparison with a
removable flag from the reader: rewording the source label silently
resurrected a button that always fails.
2026-09-09 09:54:27 +07:00
nntrivi2001 0ad4b8c920 fix(cc-config): stop pinning CLAUDE_CONFIG_DIR when CLAUDE_HOME is unset
runClaude() unconditionally set CLAUDE_CONFIG_DIR to getClaudeHome()
(~/.claude), so the CLI wrote ~/.claude/.claude.json — a file
cc-discovery.getClaudeJsonPath() never reads (it reads ~/.claude.json,
beside ~/.claude/, by design). Every default-install MCP add/remove and
plugin/marketplace mutation was inert: add toasted success and vanished on
refetch, remove 422'd for every server the panel lists.

With CLAUDE_HOME unset (the default), leave CLAUDE_CONFIG_DIR unset too —
the CLI's own default config dir already lines up with what cc-discovery
reads, so alignment comes from doing nothing. Only pin it when the operator
explicitly set CLAUDE_HOME, which is the one case the pin is meant for.
Left a residual, documented at both sites: with CLAUDE_HOME set, reads still
come from $HOME/.claude.json while the CLI writes
<CLAUDE_HOME>/.claude.json — a split predating this task, affecting a
configuration nobody here runs.

New tests pin the env contract directly (no real CLI needed): runClaude
must not set CLAUDE_CONFIG_DIR when CLAUDE_HOME is absent, and must set it
to CLAUDE_HOME's value when present.
2026-09-09 09:48:28 +07:00
nntrivi2001 6d27c115d8 fix(cc-config): MCP scope was inverted — expose user/local, not user/project
readMcpServers only ever parses ~/.claude.json (top-level = user scope,
projects[<root>] = the panel's "projectScoped" bucket, which the CLI itself
calls `local`). The previous user/project scope choice was the exact
inverse: `--scope project` writes .mcp.json, which readMcpServers never
reads (a successful add would vanish), and every "projectScoped" row was
actually `local`, so Remove sent a scope the CLI could never match.

- cc-mcp-cli.js: MCP_SCOPES -> ["user", "local"]; drop the local
  mutationQueue/serialize in favor of cc-plugin-cli.js's exported one, so a
  plugin install and an MCP add can't spawn `claude` concurrently; drop the
  String(v) coercion in env/header flags (a wrong-typed value is now a 400,
  not a written "[object Object]"); assertEmbeddedValue allows empty strings
  (a legitimate empty stdio arg was previously a 400).
- cc-plugin-cli.js: export `serialize` for reuse.
- CcConfig.tsx: CcMcpScope narrowed to user/local; the projectScoped bucket's
  McpCard now sends scope="local"; rows sourced from
  ~/.claude/settings.json (no CLI scope addresses them) no longer render a
  Remove button. Cleaned up i18n keys my own earlier change made dead
  (explain.mcp.addCmd/remove/removeButton) and gave the scope <select> its
  own short option labels instead of reusing full-sentence section headings.
2026-09-09 09:36:52 +07:00
nntrivi2001 fd4edaac98 feat(cc-config): add and remove MCP servers through the claude CLI
Mutation goes through `claude mcp add|remove`, never a direct edit of
~/.claude.json — the running CLI rewrites that file concurrently, so a
dashboard write would race it. Reads stay on readMcpServers.

The plan specified a --url flag; the real CLI (v2.1.266) has none —
commandOrUrl is positional, and --header must trail it or the variadic
flag swallows the url. Built against the verified shape.
2026-09-09 09:15:53 +07:00
nntrivi2001 22fd4797aa Revert "feat(cc-config): edit settings.json values from the dashboard"
This reverts commit ce64132771.
2026-09-09 08:47:46 +07:00
nntrivi2001 ce64132771 feat(cc-config): edit settings.json values from the dashboard
Replace the batch multi-key settings.json editor with a recursive per-leaf
editor: each scalar value gets its own inline edit control, the dotted path
is rebuilt from the node's ancestry, and a redacted value renders as a
static non-editable chip. Saves go straight through the existing
PATCH /api/cc-config/settings per-key merge.
2026-09-09 08:44:57 +07:00
nntrivi2001 ed418d5696 fix(codex): treat a literal null from the CLI as an empty list, not a 500 2026-09-09 01:29:17 +07:00
nntrivi2001 42db57ee18 fix(codex-config): stop rejecting env values that start with "-"
--env's value is the RHS of one KEY=VALUE argv element, never its own
argv position, so a leading "-" can never be parsed as a flag — unlike
the key, which stays validated as an identifier. assertArg's leading-dash
guard was reused for the value too, refusing ordinary values like "-1".
Split it into assertEnvValue (no leading-dash check) for values only.
2026-09-09 01:22:12 +07:00
nntrivi2001 acff059e92 fix(codex-config): close whole-branch review defects in the Codex panel
Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
2026-09-09 01:17:06 +07:00
nntrivi2001 fef3e640d0 fix(codex): add the missing common.close key so the backups dialog announces itself 2026-09-08 21:40:23 +07:00
nntrivi2001 4d4449866b fix(codex): let plugin remove take the same spec plugin add and list use 2026-09-08 21:37:41 +07:00
nntrivi2001 56bf5bbf4d fix(codex-config): close fix-round-1 parity gaps against CcConfig
Isolate the codex-CLI-backed reads (mcp/plugins/marketplaces) from the
file-backed ones in fetchAll via two independent Promise.all groups, so a
missing `codex` binary degrades only the mcp/plugins tabs instead of
leaving the whole panel stuck on skeletons. Generalize EditorModal's
nameless-type detection behind a `namelessTypes` prop (defaulting to
Claude's own ["memory"]) so Codex's config.toml can be created without a
name. Port BackupsModal, disable the plugin/mcp Remove button when no
stable identifier resolved, hide the inert search box on memory/config,
drop a redundant empty state, and reword the delete-dialog copy to be
tool-neutral.

Co-authored-by: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
2026-09-08 21:34:06 +07:00
nntrivi2001 8015730adf feat(codex): create and customize Codex config from /settings
Adds the Codex config panel (skills/agents/rules/memory/config editing,
mcp/plugins/marketplaces add-remove via the codex CLI) as the last piece
of parity with the Claude Code settings tab, plus the codex_config_changed
WebSocket type the client was missing, and docs for the new
/api/codex-config surface.
2026-09-08 21:09:28 +07:00
nntrivi2001 f6973d24d6 refactor(config): make EditorState/ConfirmDeleteState generic over the artifact type
Review feedback: string widening dropped the compile-time guard at the
write/delete boundary right where the Codex panel (the next task) starts
writing into this state. Threading <TType extends string = CcArtifactType>
through EditorState/EditorModal and ConfirmDeleteState/ConfirmDeleteModal
keeps CcConfig.tsx's write/delete calls checked against CcArtifactType by
default while still letting a Codex-typed caller instantiate its own union
later, removing the two `as CcArtifactType` casts.
2026-09-08 20:48:58 +07:00
nntrivi2001 865c6c1d1e refactor(config): lift the tool-agnostic config modals out of CcConfig
Moves FileViewer, EditorModal, ConfirmDeleteModal, ToastNotice, MdItemList,
ScopeBadge, and the shared atoms (Empty/SkeletonRows/CopyButton/formatBytes)
into client/src/components/config/ so the upcoming Codex config panel can
reuse them. EditorModal now takes a fetchContent callback instead of calling
api.ccConfig.file itself; CcConfig.tsx supplies the Claude-specific fetcher.
The moved EditorState/ConfirmDeleteState type field widens from
CcArtifactType to string so non-Claude callers can use them too, with a cast
at the two remaining Claude-specific call sites in CcConfig.tsx.
2026-09-08 20:40:47 +07:00
nntrivi2001 e38dc5555c fix(codex): keep element types through the array coercion, model mcpAdd as a union 2026-09-08 20:31:10 +07:00
nntrivi2001 8021e569f2 fix(codex): add array coercion and typed arguments for CLI-backed routes
- Coerce mcp/plugins/marketplaces GET responses to arrays (CLI may emit
  object), degrading to empty list rather than crash on unexpected shape
- Add typed interfaces: CodexMcpAddArgs, CodexPluginActionArgs,
  CodexMarketplaceActionArgs with validated action literals
- Mirrors ccConfig's type safety across the namespace
2026-09-08 20:27:52 +07:00
nntrivi2001 2ae9b06652 feat(codex): add the codexConfig API namespace
Adds a typed client wrapper for the Codex config API alongside ccConfig,
with methods for reading/writing skills, agents, rules, memory, config,
MCP servers, plugins, and marketplaces. Mirrors ccConfig construction and
error handling. All response shapes match the server's wire format.
2026-09-08 20:20:59 +07:00
nntrivi2001 aca8fd02ee fix(codex): stop 502ing empty CLI output, GET /file no longer mkdirs or leaks paths 2026-09-08 20:15:12 +07:00
nntrivi2001 29ed86d672 fix(codex): close symlink escape on GET /file, pin project writes to server cwd 2026-09-08 20:07:57 +07:00
nntrivi2001 32839332f2 feat(codex): expose the Codex config API 2026-09-08 17:54:36 +07:00
nntrivi2001 58623f37c0 fix(codex): close parent-dir symlink escape in the containment check 2026-09-08 17:43:09 +07:00
nntrivi2001 f3a2f41ed7 fix(codex): close symlink write escape and validate listBackups scope/type 2026-09-08 17:34:10 +07:00
nntrivi2001 c9541dc509 feat(codex): write Codex artifacts with backup and atomic rename 2026-09-08 17:22:31 +07:00
nntrivi2001 dcd65f891c fix(codex): support mcp add --url form, guard specs/URLs, cover --env 2026-09-08 17:15:24 +07:00
nntrivi2001 ba79610697 feat(codex): wrap the codex mcp/plugin CLI in argv-safe calls 2026-09-08 17:06:44 +07:00
nntrivi2001 b0788eea80 fix(codex): backslash parity in the TOML closing-quote scan
Round 2's escape check rejected a quote whenever a single backslash
preceded it, so an escaped backslash before a real closing quote (e.g.
path = "path\\" # c) was misread as an escaped quote and the field was
dropped. Now counts the run of backslashes and only treats an ODD run as
escaping the quote. Folded the old two-branch quote handling into one
scan (it already finds the terminator correctly with or without a
trailing comment) and decode \\ -> \ in the extracted value, since the
scan already knows which backslash run is real.
2026-09-08 16:57:02 +07:00
nntrivi2001 236e94760f fix(codex): escape-aware closing-quote scan, honest symlink coverage
readTomlMeta's trailing-comment stripper found the first quote character
regardless of escaping, so a description containing \"escaped quotes\"
before a real comment got silently truncated instead of dropped. The scan
now skips a quote preceded by a backslash.

The "never walks outside" containment test passed by construction (row.file
is always built off a fixed root) and would not fail if the isUnder guard
were removed. Replaced it with two tests that actually pin the regression
risk: a skill dir and an agent .toml symlinked in from outside CODEX_HOME,
asserting the row is discovered and its content read through the link.
isUnder stays as a defensive check (matches cc-discovery.js), now commented
as such.
2026-09-08 16:48:45 +07:00
nntrivi2001 98b1921ca6 fix(codex): keep commented TOML fields, follow symlinked config surfaces
readTomlMeta was dropping any field with a trailing `# comment` (common in
real agents/*.toml). readSkills/readAgents/readRules used bare
isDirectory()/isFile(), which miss symlinked entries per cc-discovery.js's
own documented Dirent quirk — reused its isDirLike/isFileLike instead
(exporting isDirLike from cc-discovery.js, which wasn't previously public).
Also swapped the "never walks outside" test for one that actually exercises
a symlink escape, and dropped the unnecessary isUnder re-export.
2026-09-08 16:41:46 +07:00
nntrivi2001 7618766861 feat(codex): read the Codex config surfaces 2026-09-08 16:32:06 +07:00
nntrivi2001 57849d2f75 refactor(codex): give the CODEX_HOME lookup its own module 2026-09-08 16:24:17 +07:00
nntrivi2001 aef7ec130b refactor(config): extract the safe-write primitives cc-mutate guards with 2026-09-08 16:17:48 +07:00
nntrivi2001 6f9b9c36b8 feat(doctor): catch half-written installs, not just absent ones
Runtime deps checked that node_modules existed, which is true of the broken
tree too — it now verifies every declared dependency landed. Dashboard UI
quotes the last client-build.log error instead of repeating the build command,
and npm and free disk get their own rows: both truncate installs silently.
2026-09-08 15:44:41 +07:00
nntrivi2001 dab01b7f2b fix(plugin): install the client build's deps with npm ci
npm install reuses whatever the cache and an earlier run left behind and still
reports success, so a truncated cache entry produced a node_modules that was
missing typescript's lib/*.d.ts — the build then died with TS2318, every global
type undefined. npm ci starts from an empty tree and verifies each tarball
against the lockfile's integrity hash. Falls back to install when the copied
client has no usable lockfile.
2026-09-08 15:43:20 +07:00
367 changed files with 47260 additions and 11000 deletions
+13 -13
View File
@@ -1,12 +1,12 @@
{
"name": "claude-code-agent-monitor-plugins",
"name": "agenthub",
"description": "Official plugin marketplace for AgentHub — the `agenthub` plugin installs the dashboard itself (hooks, server, CLI), plus 10 focused plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
"owner": {
"name": "smartgift",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"plugins": [
{
"name": "agenthub",
@@ -16,61 +16,61 @@
},
{
"name": "agenthub-analytics",
"path": "plugins/agenthub-analytics",
"source": "./plugins/agenthub-analytics",
"description": "Deep analytics on Claude Code sessions — token usage (total_input/total_output/total_cache_read/total_cache_write with compaction baselines pre-summed), cost breakdowns via the pricing engine, usage trends over 365 days, cache efficiency, model mix, and productivity scoring.",
"tags": ["analytics", "tokens", "cost", "productivity"]
},
{
"name": "agenthub-cost-guard",
"path": "plugins/agenthub-cost-guard",
"source": "./plugins/agenthub-cost-guard",
"description": "Budget guardrails — set spend limits, forecast week/month-end cost from daily trends, surface cost-threshold alerts via the alert-rule API, and quantify model-routing savings.",
"tags": ["budget", "cost", "forecast", "alerts"]
},
{
"name": "agenthub-productivity",
"path": "plugins/agenthub-productivity",
"source": "./plugins/agenthub-productivity",
"description": "Productivity automation — daily standups, weekly/monthly reviews, sprint summaries, time-of-day focus analysis, and workflow optimization using the workflow intelligence API's 11 datasets.",
"tags": ["productivity", "reports", "workflows", "standup"]
},
{
"name": "agenthub-devtools",
"path": "plugins/agenthub-devtools",
"source": "./plugins/agenthub-devtools",
"description": "Developer tools for the Agent Monitor — session debugging with full event-chain inspection, data-integrity inspection, event tracing, transcript search, hook diagnostics, data export (JSON/CSV/Markdown), and system health checks.",
"tags": ["devtools", "debug", "diagnostics", "export"]
},
{
"name": "agenthub-insights",
"path": "plugins/agenthub-insights",
"source": "./plugins/agenthub-insights",
"description": "AI-powered insights — pattern detection via workflow intelligence (tool flow transitions, agent co-occurrence), anomaly alerting, trend forecasting, regression watch, benchmarking, optimization recommendations, and session comparison.",
"tags": ["insights", "patterns", "anomaly", "optimization"]
},
{
"name": "agenthub-sessions",
"path": "plugins/agenthub-sessions",
"source": "./plugins/agenthub-sessions",
"description": "Session forensics — search by project/model/status, reconstruct event timelines, replay transcripts turn-by-turn, roll up activity per working directory, and review stale sessions before cleanup.",
"tags": ["sessions", "search", "transcript", "replay"]
},
{
"name": "agenthub-workflows",
"path": "plugins/agenthub-workflows",
"source": "./plugins/agenthub-workflows",
"description": "Multi-agent orchestration & fleet intelligence — map the subagent DAG, audit model delegation and effectiveness, report concurrency lanes, trace error propagation by depth, and review Workflow-tool fleet runs.",
"tags": ["workflows", "orchestration", "subagents", "fleet"]
},
{
"name": "agenthub-quality",
"path": "plugins/agenthub-quality",
"source": "./plugins/agenthub-quality",
"description": "Reliability & SLOs — scan for API errors and tool failures, audit hook delivery health (PreToolUse/PostToolUse balance), track completion/error-rate SLOs with error budgets, and alert on reliability regressions.",
"tags": ["reliability", "errors", "slo", "quality"]
},
{
"name": "agenthub-config",
"path": "plugins/agenthub-config",
"source": "./plugins/agenthub-config",
"description": "Claude Code config & memory governance — audit skills/subagents/commands/MCP servers/hooks/settings via the Config Explorer API and curate the file-based memory store (CLAUDE.md + per-project auto-memory files).",
"tags": ["config", "memory", "governance", "mcp"]
},
{
"name": "agenthub-dashboard",
"path": "plugins/agenthub-dashboard",
"source": "./plugins/agenthub-dashboard",
"description": "Dashboard connector — quick status checks, one-line metrics, live activity watch, and endpoint probing against the Agent Monitor API.",
"tags": ["dashboard", "status", "connector"]
}
+2 -2
View File
@@ -5,8 +5,8 @@
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git",
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
"license": "MIT",
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "claude-code"],
"commands": [
+4
View File
@@ -38,5 +38,9 @@
"Bash(claude --version)",
"Bash(gh pr *)"
]
},
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
# Optional dev-container image for AgentHub.
# Node 22 to match the production Dockerfile and the project's engines.
# Node 22 to match the project's engines.
# Adds the native-addon toolchain (better-sqlite3 builds via node-gyp) plus
# Python 3 (statusline.py and helper scripts) and the sqlite3 CLI.
# Python 3 (scripts/expand-ts-module-docs.py) and the sqlite3 CLI.
#
# This image is ONLY used by Dev Containers / Codespaces. Host-based development
# (npm run dev / npm start) is unaffected.
+4 -4
View File
@@ -22,13 +22,13 @@ dependencies (`npm run setup`).
| Component | Detail |
| ---------------- | ------------------------------------------------------------------- |
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` (matches prod) |
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` |
| Native toolchain | `build-essential` + `python3` so `better-sqlite3` compiles |
| Python | `python3` / `python` for `statusline.py` and helper scripts |
| Python | `python3` / `python` for `scripts/expand-ts-module-docs.py` |
| sqlite3 CLI | inspect the dashboard DB during development |
| Features | GitHub CLI, Docker-in-Docker (build/run the project's own Dockerfile) |
| Features | GitHub CLI |
| Forwarded ports | `4820` (server API + WebSocket), `5173` (Vite client) |
| Editor | ESLint + Prettier (format on save), Vitest, Docker, YAML, Tailwind |
| Editor | ESLint + Prettier (format on save), Vitest, YAML, Tailwind |
## Everyday commands
+5 -9
View File
@@ -4,9 +4,9 @@
// Used only when you choose "Dev Containers: Reopen in Container" (VS Code) or
// open the repo in a GitHub Codespace. It changes nothing for host-based dev.
//
// Covers the full project: the Express server (4820), the React/Vite client
// (5173) and the VS Code extension. The native `better-sqlite3`
// addon builds here (build-essential + python3 are installed in the Dockerfile).
// Covers the full project: the Express server (4820) and the React/Vite
// client (5173). The native `better-sqlite3` addon builds here
// (build-essential + python3 are installed in the Dockerfile).
//
// NOTE (issue #193): Claude Code hooks are a HOST-side concern. This container
// intentionally does NOT bind-mount ~/.claude and does NOT install hooks — the
@@ -20,10 +20,7 @@
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
// Lets you build/run the project's own production Dockerfile + docker-compose
// from inside the dev container (e.g. to reproduce issue #193 deliberately).
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Server (API + WebSocket) and the Vite client dev server. VS Code forwards
@@ -40,7 +37,7 @@
}
},
// Install root + client + vscode-extension deps.
// Install root + client deps.
// Never installs Claude Code hooks (host-only — see note above).
"postCreateCommand": "bash .devcontainer/post-create.sh",
"waitFor": "postCreateCommand",
@@ -59,7 +56,6 @@
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"vitest.explorer",
"ms-azuretools.vscode-docker",
"redhat.vscode-yaml",
"yzhang.markdown-all-in-one",
"ms-python.python",
+1 -1
View File
@@ -7,7 +7,7 @@
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
echo "▶ Installing server + client + vscode-extension dependencies (npm run setup)…"
echo "▶ Installing server + client dependencies (npm run setup)…"
npm run setup
cat <<'EOF'
-15
View File
@@ -1,15 +0,0 @@
node_modules/
client/node_modules/
client/dist/
data/
.git/
.github/
*.md
!README.md
images/
wiki/
index.html
og-image.svg
favicon.svg
.prettierrc
.prettierignore
-10
View File
@@ -13,13 +13,3 @@
*.ttf binary
*.eot binary
*.db binary
# ── GitHub Linguist overrides — keep language stats honest ──────────────────
# wiki/mermaid.min.js is a vendored, minified third-party library (Mermaid);
# it is not project source code and must not count toward language stats.
wiki/mermaid.min.js linguist-vendored
# wiki/i18n-content.js is machine-assembled translation data (see its header:
# "AUTO-GENERATED wiki body-content translations ... Do not hand-edit").
# It is a data bundle keyed by English innerHTML, not hand-written JavaScript.
wiki/i18n-content.js linguist-generated
-12
View File
@@ -9,17 +9,5 @@ package-lock.json
client/package-lock.json
.vs/
*.md
index.html
wiki/index.html
*.yml
*.yaml
desktop/out/
desktop/release/
desktop/node_modules/
desktop/package-lock.json
desktop/assets/*.svg
desktop/assets/*.png
desktop/assets/*.icns
desktop/assets/icon.iconset/
wiki/mermaid.min.js
fonts/*.woff2
-130
View File
@@ -1,130 +0,0 @@
# Superpowers Workspace
This directory contains project-specific configuration and artifacts for the Superpowers workflow.
It acts as the working memory layer for agent-driven development, enabling structured planning, execution, and iteration across sessions.
---
## Purpose
Superpowers transforms coding agents into structured, process-driven collaborators. Instead of jumping straight into code, the agent:
1. Clarifies intent
2. Produces a design
3. Breaks work into executable steps
4. Executes via subagents
5. Verifies and iterates
This directory stores the outputs of that workflow so progress is persistent, inspectable, and reproducible.
---
## Directory Structure
Typical contents may include:
```
.superpowers/
├── brainstorm/ # Design explorations and refined specs
├── plans/ # Task breakdowns and execution plans
├── reviews/ # Code review outputs and feedback
├── runs/ # Execution logs or agent traces
└── README.md # This file
```
> Exact structure may evolve depending on which skills are triggered.
---
## Workflow Overview
Superpowers operates through automatic skill activation:
### 1. Brainstorming
* Refines vague ideas into concrete specs
* Explores alternatives
* Produces structured, reviewable design docs
### 2. Planning
* Converts approved designs into granular tasks
* Each task is:
* Small (2–5 min)
* Explicit (exact file paths + code)
* Verifiable
### 3. Execution
* Tasks are executed by subagents
* Includes:
* Spec compliance checks
* Code quality review
* Can run sequentially or in parallel
### 4. Verification
* Enforces test-first development (TDD)
* Ensures correctness before completion
* Prevents silent regressions
### 5. Completion
* Validates final state
* Offers merge / PR / discard options
* Cleans up working branches
---
## Key Principles
* **Test-first development** (RED → GREEN → REFACTOR)
* **Small, deterministic tasks**
* **Explicit over implicit**
* **Process over intuition**
* **Verification over assumption**
---
## How to Use
You don’t interact with this directory directly most of the time.
Instead:
* Start a task in your coding agent (Claude, Cursor, etc.)
* Let Superpowers skills activate automatically
* Review outputs when prompted (designs, plans, reviews)
Artifacts will be written here as the workflow progresses.
---
## When to Look Here
Check this directory when you want to:
* Review the current plan
* Inspect prior design decisions
* Debug agent behavior
* Resume interrupted work
* Audit what was executed
---
## Notes
* Files here are **source-of-truth for agent state**
* Safe to commit (recommended for team workflows)
* Avoid manual edits unless you understand the workflow implications
---
## Related
* Project root `CLAUDE.md` → global agent context
* `.claude/` → rules, skills, and subagents
* Superpowers upstream docs →
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
{"reason":"idle timeout","timestamp":1774536800234}
@@ -1 +0,0 @@
1542
+146 -253
View File
File diff suppressed because one or more lines are too long
+16 -4
View File
File diff suppressed because one or more lines are too long
-67
View File
@@ -1,67 +0,0 @@
# Multi-stage Dockerfile for AgentHub - a Node.js server with a React client.
# This setup optimizes the final image size by separating the build and runtime stages.
# The first stage installs only the production dependencies for the server, while the second stage builds the React client.
# The final stage combines the necessary files and dependencies to run the application in production.
# Compatibility: This setup is compatible with both Podman and Docker. Runnable on any platform that supports Node.js and Alpine Linux.
#
# Author: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
# ── Stage 1: Install server production deps ───────────────────────────
FROM node:22-alpine AS server-deps
WORKDIR /app
COPY package.json package-lock.json ./
# The root `postinstall` hook (scripts/postinstall.js) fires during `npm ci`, so
# the file must exist here or npm aborts with MODULE_NOT_FOUND before installing
# anything. It self-skips when client/ is absent (as it is in this stage), so
# copying just the one script keeps this deps-cache layer from busting on
# unrelated scripts/ edits. Do NOT use --ignore-scripts: that would also skip
# better-sqlite3's prebuild fetch and silently drop the native SQLite driver.
COPY scripts/postinstall.js ./scripts/postinstall.js
RUN npm ci --omit=dev
# ── Stage 2: Build React client ───────────────────────────────────────
FROM node:22-alpine AS client-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
# vite.config.ts stamps the UI version from the repo-root package.json (one level
# up from the client dir). Provide it here so the built client shows the real
# release version; the config falls back gracefully if it is ever absent.
COPY package.json /app/package.json
RUN npm run build
# ── Stage 3: Production runtime ───────────────────────────────────────
FROM node:22-alpine
WORKDIR /app
# Runs Claude Code sessions inside a named tmux session per lane so both the
# dashboard (via node-pty attach) and a real terminal can share one live pane.
RUN apk add --no-cache tmux
COPY --from=server-deps /app/node_modules ./node_modules/
COPY package.json ./
COPY server/ ./server/
COPY scripts/ ./scripts/
COPY statusline/ ./statusline/
COPY --from=client-build /app/client/dist ./client/dist/
RUN mkdir -p data
EXPOSE 4820
ENV NODE_ENV=production
# In a container the app MUST bind all interfaces: the server binds loopback by
# default (GHSA-gr74-4xfh-6jw9), but a container's loopback is a separate
# namespace the published port cannot reach, so a loopback bind makes the port
# unreachable. Inside a container the trust boundary is the *host* port publish
# (keep it on 127.0.0.1 — see docker-compose.yml / INSTALL.md), not this bind.
ENV DASHBOARD_HOST=0.0.0.0
# The recommended ~/.claude bind mount is read-only, so the server cannot write
# its default data dir (~/.claude/agenthub). Persist to the mounted
# volume at /app/data instead.
ENV DASHBOARD_DATA_DIR=/app/data
CMD ["node", "server/index.js"]
+128 -92
View File
@@ -7,8 +7,8 @@ A step-by-step guide to get AgentHub — the local control surface for Claude Co
If all you want is a working dashboard, skip every step below. From Claude Code:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install agenthub@claude-code-agent-monitor-plugins
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
/plugin install agenthub@agenthub
```
The next session start installs the hooks, boots the server, puts the `agenthub` CLI
@@ -30,17 +30,117 @@ dashboard, run it from source, or stay on Node 20.
| Node.js | 20+ (22+ recommended) | Required for server and client |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Python | 3.6+ | Optional — statusline utility only |
| Git | Any | For cloning the repository |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, and the Codex nudge. See [Step 0](#step-0--system-packages) |
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` and the PR stages of `/agenthub:ship-full` |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
---
## Step 0 — System packages
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
backs the run console**. The dashboard shells out to the `tmux` binary and has
no bundled fallback, so without it **Start Run** and `agenthub lanes shell`
fail while everything else keeps working.
```bash
sudo apt install git tmux # Debian / Ubuntu
sudo dnf install git tmux # Fedora / RHEL
brew install git tmux # macOS
sudo pacman -S git tmux # Arch
```
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
Check it:
```bash
tmux -V # e.g. tmux 3.4
git --version
node -v # v20.x / v22.x / v24.x
```
Once the server is up you can ask it directly — this is the same probe the
dashboard uses:
```bash
curl -s localhost:4820/api/run/tmux # {"available":true}
```
**No configuration is needed.** AgentHub creates its own session per lane,
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
depends on (mouse mode is set on the session explicitly). You can attach to the
exact same pane from a normal terminal:
```bash
agenthub lanes shell # from inside the lane's directory
tmux attach -t agenthub-lane-7 # or by name
tmux ls # every dashboard session is prefixed agenthub-lane-
```
Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
the browser terminal and your local terminal are two clients on one pane, and
restarting the dashboard never stops a run.
### macOS
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
covered by Linux-side unit tests only; the checklist at the end of this section
is what a Mac user runs to confirm them.
```bash
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
```
- **Node:** a plugin install needs **>= 22.5** (it stores data through
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **No systemd, so no auto-start on boot.** The server is a detached process
that runs until logout or reboot. It comes back at the next Claude Code
session start (within about 10 s), or by hand with `agenthub start`. A
discovery entry left over from before the reboot is ignored, so a recycled
pid can never pass for a live dashboard.
- **Started from a Claude Code session.** The server drops the variables that
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
an older build started keeps its dirty global env until it exits
(`tmux kill-server` once, with no run you care about open).
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
volume becomes the one on disk — that is what sessions report, so they bind.
Lanes created before this change keep the string they were created with.
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
macOS may ask once for permission the first time.
- **Option key** in the browser terminal types special characters (it is not
sent as Meta).
**Mac checklist** — please run it and report the result on issue #22:
1. Fresh plugin install from a Terminal `claude` session: `/agenthub-doctor` is all OK (the systemd row says not available) and the dashboard answers on `http://localhost:4820`.
2. `tmux show-environment -g | grep -i claude` prints nothing.
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form.
5. Changes panel: diff, stage and commit on a lane.
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
8. Reboot, open `claude`, and the dashboard comes back within about 10 s.
9. `/agenthub-update` completes and the server restarts on the new version.
---
## Step 1 — Clone the repository
```bash
git clone https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git
cd Claude-Code-Monitor
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
cd AgentHub
```
---
@@ -85,7 +185,7 @@ This starts two processes concurrently:
Open **http://localhost:5173** in your browser.
> [!TIP]
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard in Docker or Podman, install hooks from the host with `npm run install-hooks` after the container is up.
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
---
@@ -120,8 +220,6 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
2. Once installed, the dashboard launches in its own window with no browser chrome
3. Offline support: previously visited pages and assets are served from the Service Worker cache when the network is unavailable
The landing page and wiki are also installable PWAs with their own manifests and service workers — visit each in a browser to install independently.
---
## Step 5 — (Optional) Import existing Claude Code history
@@ -223,89 +321,6 @@ See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
---
## Optional: VS Code extension
**AgentHub** is also available as a dedicated VS Code extension for seamless, integrated monitoring.
<p align="center">
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
</p>
### Features
- **Real-time Sidebar**: Monitor agent status, health, and usage stats in the Activity Bar.
- **Pulse Status Bar**: High-level session and agent counts in the bottom status bar.
- **Direct Navigation**: Jump to specific dashboard pages or recent sessions.
- **Embedded Dashboard**: Full dashboard interface within a native VS Code tab.
### Installation
1. Open the [vscode-extension](./vscode-extension) folder in VS Code.
2. Install via the Marketplace or package it manually:
```bash
cd vscode-extension
npm install
# Generate .vsix for local install
npm run package
```
3. After installation, ensure the main dashboard server is running (`npm run dev`).
4. Look for the **Radar icon** in your VS Code Activity Bar.
For advanced configuration, refer to the [.vscode](./.vscode) and [vscode-extension](./vscode-extension) directories.
> [!TIP]
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
---
## Container mode (Docker / Podman)
The repository includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. Docker and Podman are both supported.
### Compose
```bash
# Docker Compose
docker compose up -d --build
# Podman Compose
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
```
Open **http://localhost:4820** in your browser.
### Plain Docker / Podman
```bash
# Docker
docker build -t agent-monitor .
docker run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v agent-monitor-data:/app/data \
agent-monitor
# Podman
podman build -t agent-monitor .
podman run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v "$HOME/.claude/agenthub:/app/data" \
agent-monitor
```
### Container notes
| Mount | Purpose |
|---|---|
| `~/.claude:/root/.claude:ro` | Lets the server import legacy Claude session history |
| `~/.claude/agenthub:/app/data` | **Canonical SQLite database** (shared with native installs) |
> [!NOTE]
> The image sets `DASHBOARD_HOST=0.0.0.0` and `DASHBOARD_DATA_DIR=/app/data` internally (baked into the `Dockerfile`). The `0.0.0.0` bind is required because a container's loopback is a separate namespace the published port cannot reach; `/app/data` is bind-mounted to `~/.claude/agenthub` on the host so Docker uses the same database as `npm start`. The trust boundary is the **host** port publish — the examples use `-p 127.0.0.1:4820:4820`, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`.
> [!IMPORTANT]
> Claude Code hooks run on the host, not inside the container. After the container is healthy on `http://localhost:4820`, run `npm run install-hooks` on the host so Claude Code posts hook events back to the containerized server. The installer refuses to run inside a container (issue #193) to avoid writing a container-internal handler path into a bind-mounted `~/.claude`; use `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only if you run Claude Code inside the same container.
---
## Troubleshooting
### `npm run setup` shows `better-sqlite3` errors
@@ -346,6 +361,27 @@ npm run setup
The Vite dev server and Express server run on different ports. Make sure both are running (`npm run dev` starts both). Open **http://localhost:5173**, not `http://localhost:4820`, during development.
### "Start Run" fails, or the terminal never attaches
The run console is tmux-backed. Check, in this order:
```bash
tmux -V # not installed → see Step 0
curl -s localhost:4820/api/run/tmux # {"available":false} → the SERVER cannot see tmux
curl -s "localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the server runs with
a different `PATH` — most often a systemd `--user` service, which does not read
your shell profile. Fix it by installing tmux system-wide (`/usr/bin/tmux`)
rather than into a shell-managed prefix, then `agenthub service uninstall &&
agenthub service install`.
The same `PATH` rule applies to `claude` and `codex`: a run starts the binary by
name, so a `claude` installed only through an interactive shell's profile is
invisible to a service-managed server. `{"found":false}` for the lane's tool is
that case.
### No sessions appearing after starting Claude Code
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
-24
View File
@@ -94,30 +94,6 @@ import-history: ## Import sessions from ~/.claude/ history
clear-data: ## Delete all data rows (preserves schema)
node scripts/clear-data.js
# ─── Docker / Podman ───────────────────────────────────────────────────────
.PHONY: docker-build
docker-build: ## Build dashboard Docker image
docker build -t claude-agent-monitor:local .
.PHONY: docker-up
docker-up: ## Start via docker-compose
docker compose up -d
.PHONY: docker-down
docker-down: ## Stop docker-compose stack
docker compose down
.PHONY: docker-logs
docker-logs: ## Tail docker-compose logs
docker compose logs -f
.PHONY: podman-build
podman-build: ## Build dashboard Podman image
podman build -t localhost/claude-agent-monitor:local .
# ─── Help ───────────────────────────────────────────────────────────────────
.PHONY: help
+109 -17
View File
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
*(Tiếng Việt: [README.vi.md](README.vi.md))*
## What it does
## Main features
**Observe**
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
searchable: tool calls, token usage, cost, subagent trees, transcripts.
- **Analytics and cost.** Per-model token totals and estimated cost, with
editable pricing rules; top tools, agent types, and workflow patterns.
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
targets that fire on them.
- **History import.** Every session under `~/.claude/projects/` is imported on
startup; archives from another machine come in through Settings → Import
History (idempotent, never double-counts tokens).
- **Remote sources.** Pull session history from other machines over SSH
(`agenthub remote-sources`).
**Drive**
- **Lanes.** One lane per working directory, surviving session restarts. A lane
moves through pipeline stages and the dashboard shows where it is.
- **Stage detection.** The stage is inferred from the tool stream, so a session
@@ -26,15 +40,89 @@ Internal build — all rights reserved.
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
setting.
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus a CLI.
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
feature from intake to PR, declaring every stage to the dashboard as it goes.
## Requirements
**Configure**
Node **>= 20** (`engines` in `package.json`). Node **24** is what the test suites
are verified on — node 25 currently breaks 6 server tests through a
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark; 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.
## Prerequisites
| Need | Version | Required for |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` lanes |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node **25** is the one version to avoid: it breaks 6 server tests through a
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change.
change. Both were gone again by 26 (1712 server + 488 client tests green on
v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub lanes shell` fail without it. Everything else
(sessions, analytics, lanes, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows has no native tmux — run AgentHub under WSL2 and install it there.
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
no systemd, so the dashboard runs until logout/reboot and comes back at the
next Claude Code session, or by hand with `agenthub start`. Details and a
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
Verify, both from your shell and from the server's point of view:
```bash
tmux -V # e.g. tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
A lane's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
### The superpowers plugin
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
@@ -68,8 +156,8 @@ Two commands on a machine that has nothing but Claude Code, no clone and no
`npm run setup`:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install agenthub@claude-code-agent-monitor-plugins
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
/plugin install agenthub@agenthub
```
The first session start installs the hooks, boots the server, puts `agenthub` on
@@ -83,7 +171,7 @@ Details, including what to delete on uninstall:
## Install from a checkout
```bash
npm run setup # root, client and vscode-extension dependencies
npm run setup # root and client dependencies
npm run build # builds the client into client/dist
npm start # serves the built client and the API on :4820
```
@@ -148,9 +236,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
@@ -169,9 +259,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
@@ -205,7 +295,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
+102 -10
View File
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
## Làm được gì
## Chức năng chính
**Quan sát**
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
được: tool call, token usage, cost, cây subagent, transcript.
- **Analytics và chi phí.** Token và cost ước tính theo từng model, với bảng
pricing sửa được; top tool, agent type và workflow pattern.
- **Activity feed và alert.** Luồng event trực tiếp, alert rule, và webhook
target bắn khi rule khớp.
- **Import lịch sử.** Mọi session dưới `~/.claude/projects/` được import lúc
khởi động; lịch sử từ máy khác đưa vào qua Settings → Import History
(idempotent, không bao giờ đếm trùng token).
- **Remote source.** Kéo lịch sử session từ máy khác qua SSH
(`agenthub remote-sources`).
**Điều khiển**
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
@@ -26,14 +40,92 @@ Internal build — all rights reserved.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane.
- **Analytics (tab trong dashboard), alert và workflow view**, cộng thêm CLI.
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
## Yêu cầu
**Cấu hình**
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
settings file, memory file, marketplace, keybinding và statusline mà Claude
Code biết — đọc được tất cả, còn sửa/tạo/xóa thì chỉ cho các bề mặt dạng file
text (skill, agent, command, output style, `CLAUDE.md`). Plugin, MCP, hook và
`settings.json` để read-only: chúng tranh ghi với CLI đang chạy.
- **Cập nhật.** Phát hiện bản mới dựa trên release, kèm nút **Update all** ngay
trong UI (marketplace → mọi plugin → AgentHub sau cùng → reload session).
- **CLI cho toàn bộ** (`agenthub`, và `agenthub repl`), cài được như PWA, UI có
tiếng Anh, Việt, Trung và Hàn.
## Yêu cầu hệ thống
| Cần | Phiên bản | Dùng cho |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`. Tới 26 thì
cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
### Cài tmux
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
```bash
tmux -V # ví dụ: tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
### Plugin superpowers
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
thì skill mới resolve được (session sau tự nhận). Cài tay:
```bash
claude plugin install superpowers@claude-plugins-official
```
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
## Cài như Claude Code plugin
@@ -41,8 +133,8 @@ Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không c
`npm run setup`:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install agenthub@claude-code-agent-monitor-plugins
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
/plugin install agenthub@agenthub
```
Lần session-start đầu tiên sẽ cài hooks, boot server, đưa `agenthub` vào PATH và
@@ -55,7 +147,7 @@ cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
## Cài từ checkout
```bash
npm run setup # cài dependency cho root, client và vscode-extension
npm run setup # cài dependency cho root và client
npm run build # build client vào client/dist
npm start # serve client đã build + API trên :4820
```
+24 -105
View File
@@ -48,48 +48,11 @@ npm run install-hooks
```
> [!TIP]
> Container note: do not rely on hook auto-install from inside Docker or Podman. The hook path written by a container would point at the container filesystem, not the host. Start the container first, then run `npm run install-hooks` on the host. As a safeguard (issue #193), the installer now **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`; the containerized server logs the same guidance instead of silently writing a bad path. If you genuinely run Claude Code inside the same container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Container runtime (Docker / Podman)
The repo includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. The container image serves the built client and API on port `4820`, stores SQLite data under `/app/data`, and can import legacy Claude history from a read-only `~/.claude` mount.
```bash
# Docker Compose
docker compose up -d --build
# Podman Compose
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
# Plain Docker
docker build -t agent-monitor .
docker run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v agent-monitor-data:/app/data \
agent-monitor
# Plain Podman
podman build -t agent-monitor .
podman run -d --name agent-monitor \
-p 127.0.0.1:4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v "$HOME/.claude/agenthub:/app/data" \
agent-monitor
```
Container-specific behavior:
- The dashboard is available at `http://localhost:4820`
- The image sets `DASHBOARD_HOST=0.0.0.0` (bind inside the container — its loopback is a separate namespace the published port cannot reach) and `DASHBOARD_DATA_DIR=/app/data` internally; both are baked into the `Dockerfile`
- The examples publish on `127.0.0.1` only, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`
- `~/.claude:/root/.claude:ro` is used for history import only
- `~/.claude/agenthub:/app/data` is the **canonical** SQLite database (shared with native installs)
- Claude Code hooks still execute on the host, so install them from the host with `npm run install-hooks`
### Environment variables
| Variable | Default | Description |
@@ -136,39 +99,18 @@ This repository ships extension files for both agent ecosystems:
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### VS Code extension setup
**AgentHub** is available as an integrated VS Code extension for seamless monitoring within your editor.
- **Activity Bar View**: Adds a custom "Radar" icon to the activity bar providing real-time agent health, token counts, and session stats.
- **Status Bar Integration**: Displays live session and agent pulse counts in the bottom bar.
- **Embedded Dashboard**: Renders the full web dashboard directly in a VS Code editor tab.
- **Automated Detection**: Automatically finds your dashboard server on ports `5173` or `4820`.
<p align="center">
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
</p>
To install or develop the extension:
1. Open the [vscode-extension](./vscode-extension) directory in VS Code.
2. Run `npm install` and `npm run package` to generate a local `.vsix` installer.
3. For developer details, see [vscode-extension/README.md](./vscode-extension/README.md).
> [!TIP]
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
### PWA configuration (optional)
The dashboard, landing page, and wiki each ship as independent Progressive Web Apps. No configuration is required — manifests and service workers are included out of the box.
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit the `manifest.json` in the relevant directory (`client/public/` for dashboard, root for landing, `wiki/` for wiki). Common fields to change:
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** Each SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
@@ -349,16 +291,6 @@ Commonly used targets:
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
| `make docker-up` | `docker compose up -d` | Start via docker-compose |
| `make docker-down` | `docker compose down` | Stop docker-compose stack |
---
## Statusline (optional)
The `statusline/` directory contains a standalone terminal statusline for Claude Code showing model, working directory, git branch, context window usage, and token counts. It is independent of the web dashboard.
See [statusline/README.md](./statusline/README.md) for installation instructions.
---
@@ -392,6 +324,26 @@ The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock
---
### The run console won't start a run / the terminal never attaches
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
```bash
tmux -V # missing → sudo apt install tmux
curl -s http://localhost:4820/api/run/tmux # {"available":true}
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the **server's**
`PATH` differs from yours — typically a systemd `--user` service, which never
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
re-run `agenthub service uninstall && agenthub service install`. The same
applies to `claude` / `codex`: a run launches them by name.
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
---
### No sessions appearing after starting Claude Code
**Check 1 — Is the server running?**
@@ -464,36 +416,3 @@ And make sure Claude Code posts hooks to the new port:
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
---
### Docker / Podman container starts but no sessions appear
**Check 1 — Is the container healthy?**
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Did you install hooks on the host?**
Hooks run on the host machine, not inside the container. After the container is up:
```bash
npm run install-hooks
```
**Check 3 — Are hooks pointing to the right port?**
Open `~/.claude/settings.json` and verify the hook commands reference `localhost:4820` (or whatever port the container is mapped to). If you changed the port mapping, update hooks accordingly.
---
### Docker build fails during `npm ci`
If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and should not block the build — `better-sqlite3` is an optional dependency. If the build still fails:
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
+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
---
+43 -26
View File
@@ -1190,12 +1190,15 @@ async function cmdPricing(flags, positional) {
// ── Updates ─────────────────────────────────────────────────────────────────
async function cmdUpdateCheck() {
console.log(c.dim("Checking the canonical remote — this can take a few seconds…"));
console.log(c.dim("Checking the repository's latest release — this can take a few seconds…"));
// POST /check (rather than GET /status) so a dashboard open in the browser
// sees the same fresh result via the update_status websocket broadcast.
const s = await post("/api/updates/check");
heading("Dashboard updates", s.repo_root || baseUrl());
if (!s.git_repo) {
// A plugin-cache install legitimately has no clone (an archive-sourced
// marketplace entry unpacks one), yet it IS checkable and updatable via the
// release API — so only a genuine non-git checkout bails out here.
if (!s.git_repo && s.install_kind !== "plugin") {
console.log(`${c.yellow("○")} ${s.message}`);
return;
}
@@ -1203,6 +1206,13 @@ async function cmdUpdateCheck() {
console.log(`${c.yellow("!")} ${s.message} ${c.dim(`(${s.fetch_error})`)}`);
return;
}
// Say WHY the release lookup produced nothing, so a green "up to date" is
// never mistaken for "the release list was actually read".
if (s.release_check && s.release_check !== "ok") {
console.log(
c.dim(` could not read the latest release (${s.release_check}) — compared commits instead`)
);
}
if (s.update_available) {
console.log(`${c.yellow("⬆")} ${s.message}`);
if (s.situation_note) console.log(c.dim(` ${s.situation_note}`));
@@ -1269,6 +1279,23 @@ async function cmdDoctor() {
);
console.log(`${c.green("✔")} WS connections ${srv.ws_connections ?? 0}`);
// Asked of the SERVER, not of this shell: a run is a tmux session the server
// spawns, so the only PATH that decides whether Start Run works is its own —
// a systemd --user service never reads your shell profile. Missing tmux is a
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// else.
try {
const { available } = await get("/api/run/tmux");
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
);
} catch {
console.log(`${c.dim("·")} tmux not reported by this server`);
}
try {
const { sources = [] } = await get("/api/remote-sources");
if (sources.length === 0) {
@@ -1772,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}`);
@@ -1786,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) &&
@@ -2766,22 +2798,6 @@ async function cmdStage(args) {
result: flag("result"),
});
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
// A stage name matching no node (nor alias) still stores — setStage takes the
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
// `current` and progress reads 0. Warn, never fail: a typo must not break a
// declaration the pipeline can still record, but it must not pass silently.
// Skipped for `--result fail`, which paints the node `failed` rather than
// `current` and would otherwise look identical to an unknown stage.
const nodes = lane.pipeline_nodes || [];
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
)
);
}
}
// ── Command catalog ─────────────────────────────────────────────────────────
@@ -2925,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",
@@ -3001,12 +3017,12 @@ const COMMAND_GROUPS = [
[
"Administration",
[
["doctor", "", "Connectivity, hooks, database, and remote sources diagnosis"],
["doctor", "", "Connectivity, hooks, database, tmux, and remote sources diagnosis"],
["info", "", "Raw system info JSON"],
["export", "[file.json]", "Export all data as JSON"],
["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"],
["reinstall-hooks", "", "Reinstall Claude Code hooks"],
["update-check", "", "Check whether the dashboard checkout is behind upstream"],
["update-check", "", "Check whether a newer release of the dashboard was published"],
["clear-data", "--yes", "Delete ALL data (requires --yes)"],
["open", "", "Open the dashboard in your browser"],
["version", "", "Print the agenthub version (also: --version, -v)"],
@@ -3270,7 +3286,8 @@ const SERVER_ONLY_REASONS = {
cleanup: "cleanup is a server-side mutation",
"clear-data": "data wipes must go through the server",
"reinstall-hooks": "hook installation is performed by the server",
"update-check": "the update check runs server-side (git fetch against the canonical remote)",
"update-check":
"the update check runs server-side (reads the repo's latest release, git fetch as fallback)",
info: "system info (uptime, memory, WS connections) only exists on a running server",
health: "health is, by definition, a check against the running server",
};
+31 -4
View File
@@ -19,8 +19,6 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
![PostCSS](https://img.shields.io/badge/PostCSS-8.5-DD3A0A?style=flat-square&logo=postcss&logoColor=white)
![Autoprefixer](https://img.shields.io/badge/Autoprefixer-10.4-DD3735?style=flat-square&logo=autoprefixer&logoColor=white)
![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-20.10-2496ED?style=flat-square&logo=docker&logoColor=white)
![Podman](https://img.shields.io/badge/Podman-4.0-CC342D?style=flat-square&logo=podman&logoColor=white)
![Prometheus](https://img.shields.io/badge/Prometheus-2.x-E6522C?style=flat-square&logo=prometheus&logoColor=white)
![Grafana](https://img.shields.io/badge/Grafana-10.x-F46800?style=flat-square&logo=grafana&logoColor=white)
![Vitest](https://img.shields.io/badge/Vitest-1.0-646CFF?style=flat-square&logo=vitest&logoColor=white)
@@ -54,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
@@ -200,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
@@ -728,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
+29 -11
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 - Claude Code Monitor</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 - Claude Code Monitor" />
<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 - Claude Code Monitor" />
<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",
@@ -61,8 +79,8 @@
"name": "SmartGift",
"url": "https://smartgift.vn"
},
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates, CLI statusline"
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates"
}
</script>
</head>
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>
+3 -1
View File
@@ -182,7 +182,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<div
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
isMain
? "bg-accent/15 text-accent"
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
}`}
>
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
+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>
+1 -1
View File
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
const color = value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
return (
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
+4 -4
View File
@@ -625,7 +625,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-400"
color="text-blue-700 dark:text-blue-400"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
@@ -672,12 +672,12 @@ export function ImportHistory() {
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events}
color="text-violet-300"
color="text-violet-700 dark:text-violet-300"
/>
<ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
value={backupResult.model_pricing}
color="text-cyan-300"
color="text-cyan-700 dark:text-cyan-300"
/>
</div>
<p className="text-[11px] text-fg-muted">
@@ -737,7 +737,7 @@ function ModeButton({
>
<div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-400" : "text-fg-secondary"
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
}`}
>
{icon}
+1 -4
View File
@@ -11,8 +11,6 @@ export interface PageTab {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
/** Small muted suffix, e.g. "soon" for a tool that is not integrated yet. */
badge?: string;
}
interface PageTabsProps {
@@ -34,7 +32,7 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
aria-label={ariaLabel}
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
>
{tabs.map(({ id, label, Icon, badge }) => {
{tabs.map(({ id, label, Icon }) => {
const isActive = id === activeId;
return (
<button
@@ -51,7 +49,6 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
>
<Icon className="w-3.5 h-3.5" />
{label}
{badge && <span className="text-[10px] text-fg-muted">{badge}</span>}
</button>
);
})}
+12 -12
View File
@@ -95,21 +95,21 @@ function StatTile({
}) {
const palette = {
default: "border-surface-3 bg-surface-2 text-fg-secondary",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
}[tone];
const iconTone = {
default: "text-fg-muted",
violet: "text-violet-400",
violet: "text-violet-700 dark:text-violet-400",
emerald: "text-status-success",
amber: "text-status-warning",
rose: "text-rose-400",
cyan: "text-cyan-400",
rose: "text-rose-700 dark:text-rose-400",
cyan: "text-cyan-700 dark:text-cyan-400",
blue: "text-blue-500",
}[tone];
@@ -359,7 +359,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-violet-400" />
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
Top tools
</h3>
<span className="text-[10px] text-fg-muted font-mono">
@@ -418,7 +418,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
Subagents
</h3>
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
@@ -508,14 +508,14 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
label: "Cache read",
value: tokens.cache_read_tokens,
cls: "bg-sky-500",
text: "text-sky-300",
text: "text-sky-700 dark:text-sky-300",
},
{
key: "cache_write",
label: "Cache write",
value: tokens.cache_write_tokens,
cls: "bg-violet-500",
text: "text-violet-300",
text: "text-violet-700 dark:text-violet-300",
},
{
key: "input",
@@ -529,7 +529,7 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
label: "Output",
value: tokens.output_tokens,
cls: "bg-orange-500",
text: "text-orange-300",
text: "text-orange-700 dark:text-orange-300",
},
];
+49 -22
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).
@@ -353,15 +360,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
};
const updateAvailable = Boolean(updateStatus?.update_available);
// Four resting states, not two: a release is out, this build is ahead of the
// newest release (dev build), it matches the newest release, or no release
// could be read at all. Collapsing the middle two into "Up to date" is what
// made a checkout carrying unreleased commits look shipped.
const checkTitle = checking
? t("nav:checkingForUpdates")
: checkError
? t("nav:checkFailed")
: updateAvailable
? t("nav:updateAvailable")
: updateStatus
? t("nav:upToDate")
: t("nav:checkForUpdates");
? updateStatus?.latest_release
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
: t("nav:updateAvailable")
: updateStatus?.dev_build
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
: updateStatus
? t("nav:upToDate")
: t("nav:checkForUpdates");
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
@@ -389,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>
@@ -497,7 +520,9 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:language")}
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{/* One column per language: a wider grid squeezed each button
below the width of its own label once Theme grew a third. */}
<div className="mt-2 grid grid-cols-2 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
@@ -507,7 +532,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
className={`rounded-md px-1 py-1.5 text-center text-[11px] font-semibold transition-colors ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
@@ -523,13 +548,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}`)}
@@ -541,8 +566,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>
);
+1 -1
View File
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
cfg.urgent
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90"
}`}
>
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
+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
+280
View File
@@ -0,0 +1,280 @@
/**
* @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 (`:focus-visible`, not the focus a click gives) 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.
* While the title is parked, a control with no other name (icon-only) carries
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
* bubble goes away with its control if that is removed from the page.
*
* ## 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.
* `[data-tip]` matches the control whose title is parked, so moving between
* its own children still resolves to it instead of re-opening the bubble. */
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
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);
// The element whose delay is running, so re-entering it does not restart it.
const pending = useRef<HTMLElement | null>(null);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
pending.current = null;
const el = parked.current;
if (el) {
// Drop the instance shim `open` installed; the prototype method is back.
delete (el as { removeAttribute?: unknown }).removeAttribute;
const text = el.dataset.tip;
if (text !== undefined) {
// React re-set `title` while it was parked (a poll flipped the hint):
// that value is current, the parked copy is stale. React never
// re-applies an unchanged prop, so writing the old one back would win
// for good.
if (!el.hasAttribute("title")) el.setAttribute("title", text);
delete el.dataset.tip;
}
if (el.dataset.tipAria !== undefined) {
el.removeAttribute("aria-label");
delete el.dataset.tipAria;
}
parked.current = null;
}
setTip(null);
setPos(null);
}, []);
const open = useCallback(
(el: HTMLElement, delay: number) => {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
pending.current = el;
timer.current = window.setTimeout(() => {
pending.current = null;
// 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");
// React removing `title` while it is parked calls removeAttribute on an
// attribute that is already gone: no MutationRecord, nothing to see —
// and `restore` would write the stale text back for good. So, on THIS
// element and only while parked, that call is taken as React's intent:
// the parked copy is dropped and the bubble hides. (React DOM calls
// `node.removeAttribute`, the instance method; `restore` deletes the
// shim.) No global prototype is touched.
el.removeAttribute = (name: string) => {
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
delete el.dataset.tip;
restore();
}
Element.prototype.removeAttribute.call(el, name);
};
// The title was an icon-only control's ONLY name; keep it named.
if (
!el.hasAttribute("aria-label") &&
!el.hasAttribute("aria-labelledby") &&
!el.textContent?.trim()
) {
el.setAttribute("aria-label", current);
el.dataset.tipAria = "";
}
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);
// Already showing or already counting down for this control: a pointer
// crossing its children must neither hide nor restart it.
if (!el || el === parked.current || el === pending.current) return;
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.
// Only KEYBOARD focus, though: a click focuses too, and without the
// `:focus-visible` check it reopened the bubble the pointerdown just
// closed, with no delay. Hover keeps its own path.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (!el || el === parked.current) return;
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
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 its anchor moves — but
// only a scroll of the document or of a box containing the anchor moves
// it. A log tailing in another panel must not close the bubble.
const onScroll = (e: Event) => {
const anchor = parked.current || pending.current;
const t = e.target;
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", restore);
window.addEventListener("blur", 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", onScroll, true);
window.removeEventListener("resize", restore);
window.removeEventListener("blur", restore);
restore();
};
}, [open, restore]);
// A control unmounted under a still cursor fires no pointerout: without this
// its bubble stays up over whatever replaced it. The same goes for a title
// re-rendered under it — the bubble would keep saying the old text, so it
// hides and `restore` keeps the new attribute.
useEffect(() => {
if (!tip) return;
const watch = new MutationObserver((records) => {
const el = parked.current;
if (!el) return;
if (
!el.isConnected ||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
)
restore();
});
watch.observe(document.body, { childList: true, subtree: true });
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
return () => watch.disconnect();
}, [tip, 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;
+133 -36
View File
@@ -1,11 +1,22 @@
/**
* @file UpdateNotifier.tsx
* @description Modal surfaced when the dashboard's git checkout is behind its
* remote tracking branch. Shows how many commits behind, the exact terminal
* command to update with copy-to-clipboard, and — when the checkout is on a
* fast-forwardable branch — an "Update now" button that calls
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
* then polls until it's back and reloads the page.
* @description Modal surfaced when a newer RELEASE of the dashboard has been
* published (or, when no release can be read, when the checkout is behind its
* remote tracking branch). Shows the release version against the running one
* with a link to its notes, the exact terminal command with copy-to-clipboard,
* and — when the install is fast-forwardable — an "Update now" button that
* calls `POST /api/updates/apply` to move onto the release tag, rebuild, and
* restart the server itself, then polls until it's back and reloads the page.
*
* A `plugin_managed` install (running out of `~/.claude/plugins/cache/`) gets
* the same button, wired to a different mechanism: the server hands it to a
* detached chain (`claude plugin update` then `plugin-bootstrap.js --force`)
* which pulls the new cache directory, reinstalls the runtime and restarts the
* dashboard. It takes minutes rather than seconds, so the progress text differs
* — and the poll waits for the old server to actually go DOWN first, because it
* keeps answering while the chain installs. New skills/slash commands still
* need `/reload-plugins` in an already-open Claude Code session; nothing a
* background process can do for it.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
@@ -14,8 +25,9 @@
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
*
* ## Dismissal persistence
* Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream
* commit re-opens the prompt. Settings can reset dismissal via the
* Dismissals are keyed in `localStorage` by the release tag when there is one,
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
* a dismissed one stays dismissed. Settings can reset dismissal via the
* `dashboard:reset-update-dismissal` window event.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -66,21 +78,29 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed upstream SHA. */
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
/** How often to silently re-check for updates in the background. */
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
* go through a git fast-forward, and `plugin_managed` goes through the detached
* `plugin-update.js` chain (`claude plugin update` then `plugin-bootstrap --force`),
* which is why it belongs here even though `applyUpdate` itself refuses it. */
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
return (
situation === "plugin_managed" ||
situation === "release_available" ||
situation === "tracking_canonical" ||
situation === "fork_or_diverged_tracking"
);
}
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
@@ -88,7 +108,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
}
/** Read the last dismissed upstream SHA from `localStorage`, or null. */
/** Read the last dismissed release tag / upstream SHA from `localStorage`. */
function loadDismissedSha(): string | null {
try {
return localStorage.getItem(DISMISS_KEY);
@@ -99,7 +119,7 @@ function loadDismissedSha(): string | null {
/**
* Git update availability modal — mounted once in {@link Layout}.
* @returns `null` when no update is available or the current SHA was dismissed.
* @returns `null` when no update is available or this release was dismissed.
*/
export function UpdateNotifier() {
const { t } = useTranslation("updates");
@@ -161,19 +181,22 @@ export function UpdateNotifier() {
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
}, []);
const show = Boolean(
status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha
);
// Keyed by the release tag when there is one, so dismissing v1.5.0 doesn't
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
// commit the user already has. Falls back to the upstream SHA for the
// commit-count path (no release readable).
const dismissKey = status?.latest_release || status?.remote_sha || null;
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
const dismiss = useCallback(() => {
if (restarting || !status?.remote_sha) return;
if (restarting || !dismissKey) return;
try {
localStorage.setItem(DISMISS_KEY, status.remote_sha);
localStorage.setItem(DISMISS_KEY, dismissKey);
} catch {
/* ignore */
}
setDismissedSha(status.remote_sha);
}, [restarting, status?.remote_sha]);
setDismissedSha(dismissKey);
}, [restarting, dismissKey]);
// Escape to dismiss - standard modal affordance.
useEffect(() => {
@@ -213,12 +236,27 @@ export function UpdateNotifier() {
// Polls `status()` until the restarted server answers again, then reloads
// so the tab picks up the new client bundle too — the server can't push
// this over its own WebSocket since it's mid-restart.
const pollUntilBack = useCallback(() => {
/**
* @param requireDownFirst wait for the server to actually disappear before
* treating a success as "it's back". The git path rebuilds and restarts
* within seconds, but the plugin chain pulls a new cache directory and
* reinstalls dependencies first — the OLD server stays up for that whole
* stretch, so polling naively would get a 200 from the process we are
* waiting to replace and reload onto the same old bundle.
*/
const pollUntilBack = useCallback((requireDownFirst = false) => {
let sawDown = !requireDownFirst;
const attempt = () => {
api.updates
.status()
.then(() => window.location.reload())
.catch(() => setTimeout(attempt, 1500));
.then(() => {
if (sawDown) window.location.reload();
else setTimeout(attempt, 1500);
})
.catch(() => {
sawDown = true;
setTimeout(attempt, 1500);
});
};
setTimeout(attempt, 1500);
}, []);
@@ -232,11 +270,17 @@ export function UpdateNotifier() {
if (result.applied) {
setApplying(false);
setRestarting(true);
pollUntilBack();
// mode "plugin": the old server lingers while the chain installs, so
// only treat a reachable server as "back" after it has gone away once.
pollUntilBack(result.mode === "plugin");
return;
}
setError(
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
result.reason === "plugin_managed"
? t("reasonPluginManaged")
: result.reason === "not_fast_forwardable"
? t("reasonNotFastForwardable")
: t("applyError")
);
} catch (e) {
setError(e instanceof Error ? e.message : t("applyError"));
@@ -249,6 +293,8 @@ export function UpdateNotifier() {
const refLabel = status.remote_ref || "origin";
const behind = status.commits_behind ?? 0;
const release = status.latest_release || null;
const pluginManaged = status.situation === "plugin_managed";
return (
<div
@@ -260,7 +306,9 @@ export function UpdateNotifier() {
if (e.target === e.currentTarget) dismiss();
}}
>
<div className="w-full max-w-lg card shadow-2xl animate-slide-up overflow-hidden">
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
longest label is a whole sentence ("restarting the dashboard…"). */}
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
@@ -272,10 +320,14 @@ export function UpdateNotifier() {
id="update-notifier-title"
className="text-sm font-semibold text-fg-primary truncate"
>
{t("title")}
{release ? t("releaseTitle", { version: release }) : t("title")}
</h2>
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
{t("commitsBehind", { count: behind, ref: refLabel })}
{/* Never print a commit count on a shallow clone — the number
is a graft artefact, not a distance. */}
{release && !status.shallow_clone && status.local_version
? t("releaseSubtitle", { current: status.local_version })
: t("commitsBehind", { count: behind, ref: refLabel })}
</p>
</div>
</div>
@@ -291,7 +343,30 @@ export function UpdateNotifier() {
{/* Body */}
<div className="px-5 py-4 space-y-3">
<p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
<p className="text-sm text-fg-secondary leading-relaxed">
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
</p>
{status.latest_release_url ? (
<a
href={status.latest_release_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
{t("viewRelease")}
{status.latest_release_name ? ` — ${status.latest_release_name}` : null}
</a>
) : null}
{/* Release lookup failed and detection fell back to commit counting:
say so, so "up to date" is never read as "release checked". */}
{status.release_check && status.release_check !== "ok" ? (
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("releaseUnavailable", { reason: status.release_check })}
</div>
) : null}
{status.fetch_error ? (
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
@@ -299,7 +374,7 @@ export function UpdateNotifier() {
</div>
) : null}
{!status.git_repo ? (
{!status.git_repo && status.install_kind !== "plugin" ? (
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
{t("notGit")}
</div>
@@ -323,7 +398,8 @@ export function UpdateNotifier() {
{/* The restart hint only applies when the printed command actually
* rewrites the working tree. Feature-branch / detached-HEAD commands
* are fetch-only - restarting the dashboard would change nothing. */}
{status.situation === "tracking_canonical" ||
{status.situation === "release_available" ||
status.situation === "tracking_canonical" ||
status.situation === "fork_or_diverged_tracking" ? (
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
) : null}
@@ -331,7 +407,22 @@ export function UpdateNotifier() {
{restarting ? (
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
{t("restarting")}
{/* The plugin chain pulls a new cache directory and reinstalls
dependencies before it restarts, so it takes minutes, not
seconds — saying "restarting" there would look hung. */}
{pluginManaged ? t("pluginUpdating") : t("restarting")}
</div>
) : null}
{/* A previous chain that failed: the only place the user would ever
learn why, since the server it would have reported from is the one
that never came back. */}
{status.last_plugin_update && status.last_plugin_update.ok === false ? (
<div className="text-xs text-status-danger bg-status-danger/5 border border-status-danger/20 rounded-lg px-3 py-2 leading-relaxed">
{t("lastPluginUpdateFailed", {
step: status.last_plugin_update.step,
error: status.last_plugin_update.error || "",
})}
</div>
) : null}
@@ -343,7 +434,10 @@ export function UpdateNotifier() {
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
{/* `whitespace-nowrap` inherits, so one class keeps every label on its
own single line; `flex-wrap` then breaks the ROW instead of the
words if a translation still outgrows the modal. */}
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
<button
type="button"
onClick={checkNow}
@@ -384,7 +478,10 @@ export function UpdateNotifier() {
) : (
<Zap className="w-4 h-4" aria-hidden />
)}
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
{/* `restartingShort`, not `restarting`: the full sentence is
already in the banner above, and a whole sentence on a button
is what forced this footer onto two lines. */}
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
</button>
) : null}
</div>
@@ -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("Claude Code Monitor")).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,288 @@
/**
* @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 userEvent from "@testing-library/user-event";
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 });
// jsdom's :focus-visible is a heuristic over the previous events and leaks
// between tests, so the tests say which kind of focus they mean: a spy answers
// `:focus-visible` from this flag and passes every other selector through.
let keyboardFocus = false;
const realMatches = Element.prototype.matches;
beforeEach(() => {
keyboardFocus = false;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
return sel === ":focus-visible"
? keyboardFocus && document.activeElement === this
: realMatches.call(this, sel);
});
});
afterEach(() => vi.restoreAllMocks());
const tabTo = (el: HTMLElement) => {
keyboardFocus = true;
act(() => el.focus());
};
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" });
tabTo(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");
});
it("keeps an icon-only control's accessible name while its title is parked", async () => {
setup(
<button title="Stage this file">
<svg aria-hidden="true" />
</button>
);
const icon = screen.getByRole("button", { name: "Stage this file" });
tabTo(icon);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
expect(icon).not.toHaveAttribute("title");
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
fireEvent.keyDown(document, { key: "Escape" });
// Put back exactly as found: no aria-label left behind.
expect(icon).toHaveAttribute("title", "Stage this file");
expect(icon).not.toHaveAttribute("aria-label");
});
it("does not flicker while the pointer crosses the control's own children", async () => {
setup(
<button title="Two parts">
<span data-testid="part-a">a</span>
<span data-testid="part-b">b</span>
</button>
);
const a = screen.getByTestId("part-a");
const b = screen.getByTestId("part-b");
hover(a);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
// Still up, with no fresh delay.
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
});
it("hides when its control is removed from the page", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Going away">x</button>
</>
);
hover(screen.getByRole("button", { name: "x" }));
await settle();
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
rerender(
<>
<GlobalTooltip />
</>
);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// A poll flips the hint while the pointer rests on the control.
rerender(
<>
<GlobalTooltip />
<button title="Up to date">pull</button>
</>
);
await settle();
// The bubble must not keep advertising the old text.
expect(screen.queryByText("Pull 2 commits")).toBeNull();
unhover(button);
expect(button).toHaveAttribute("title", "Up to date");
});
it("leaves no title behind when React removed it while the bubble was up", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// React "removes" an attribute that is already parked: no MutationRecord.
rerender(
<>
<GlobalTooltip />
<button>pull</button>
</>
);
await settle();
unhover(button);
expect(button).not.toHaveAttribute("title");
expect(button).not.toHaveAttribute("data-tip");
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setup(<button title="Second control">second</button>);
// A click is pointer focus: :focus-visible does not match.
const button = screen.getByRole("button", { name: "refresh" });
await user.click(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.queryByTestId("global-tooltip")).toBeNull();
tabTo(screen.getByRole("button", { name: "second" }));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
});
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
render(
<>
<GlobalTooltip />
<div data-testid="scroller">
<button title="Inside the scroller">inside</button>
</div>
<div data-testid="elsewhere" />
</>
);
const button = screen.getByRole("button", { name: "inside" });
hover(button);
await settle();
fireEvent.scroll(screen.getByTestId("elsewhere"));
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
fireEvent.scroll(screen.getByTestId("scroller"));
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Inside the scroller");
});
it("hides when the document itself scrolls", async () => {
setup();
hover(screen.getByRole("button", { name: "refresh" }));
await settle();
fireEvent.scroll(document);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
});
@@ -90,6 +90,95 @@ describe("UpdateNotifier — Update now", () => {
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
});
it("offers Update now for a plugin-cache install, noting the session still needs a reload", async () => {
// The button works here via the detached `claude plugin update` chain — but
// it moves only the dashboard server, so the note about /reload-plugins is
// part of the promise, not decoration.
statusMock.mockResolvedValue({
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
shallow_clone: true,
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
manual_command: "claude plugin marketplace update sg && claude plugin update agenthub@sg -y",
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument();
});
it("still offers the button when a plugin install has no git clone at all", async () => {
// The 1.5.1 bug: an archive-sourced marketplace entry leaves no .git, so
// git_repo is legitimately false. Gating the UI on that reported "not a git
// clone; check manually" for an install the dashboard can check AND update.
statusMock.mockResolvedValue({
...BASE_STATUS,
git_repo: false,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.1",
release_check: "ok",
latest_release: "v1.6.0",
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument();
});
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
statusMock.mockResolvedValue({
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
shallow_clone: true,
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
last_plugin_update: {
ok: false,
step: "resolve_claude",
error: "the `claude` CLI is not on this server's PATH",
finished_at: new Date().toISOString(),
},
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
expect(screen.getByText(/resolve_claude/)).toBeInTheDocument();
});
it("titles the modal with the release version when one was read", async () => {
statusMock.mockResolvedValue({
...BASE_STATUS,
situation: "release_available",
release_check: "ok",
local_version: "1.5.0",
latest_release: "v1.6.0",
commits_behind_release: 3,
commits_ahead_of_release: 0,
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
// A release IS fast-forwardable on a checkout, so the button stays.
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
});
it("surfaces the decline reason instead of restarting on a 409", async () => {
applyMock.mockResolvedValue({
...BASE_STATUS,
@@ -0,0 +1,163 @@
/**
* @file ConfigTabBar.tsx
* @description The tab strip both config explorers wear: one horizontally
* scrollable row with fade edges and chevrons that appear only when there is
* something to scroll to.
*
* Shared rather than copied because the two pages must look the same. Codex
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
* read as a different component sitting next to Claude Code's twelve-tab
* scroller. One row that scrolls is the same shape at any tab count.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
export interface ConfigTab<K extends string> {
key: K;
icon: ComponentType<{ className?: string }>;
label: string;
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
count: number | null;
}
interface ConfigTabBarProps<K extends string> {
tabs: ConfigTab<K>[];
current: K;
onSelect: (key: K) => void;
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
testIdPrefix: string;
ariaLabel: string;
}
export function ConfigTabBar<K extends string>({
tabs,
current,
onSelect,
testIdPrefix,
ariaLabel,
}: ConfigTabBarProps<K>) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Update scroll affordances when content size or scroll position changes.
const updateAffordances = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);
useEffect(() => {
updateAffordances();
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateAffordances, { passive: true });
const ro = new ResizeObserver(updateAffordances);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateAffordances);
ro.disconnect();
};
}, [updateAffordances]);
// Scroll the active tab into view when it changes (e.g. user picks a tab
// that's offscreen, or window resize hides the active one).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
if (!active) return;
const elRect = el.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if (activeRect.left < elRect.left + 8) {
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
} else if (activeRect.right > elRect.right - 8) {
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
}
}, [current]);
const scrollByButton = (dir: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
};
return (
<div className="relative rounded-xl border border-border bg-surface-1">
{/* Left edge gradient + chevron */}
<div
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
canScrollLeft ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollLeft && (
<button
onClick={() => scrollByButton(-1)}
aria-label="scroll tabs left"
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<div
ref={scrollRef}
role="tablist"
aria-label={ariaLabel}
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{tabs.map(({ key, icon: Icon, label, count }) => {
const active = current === key;
return (
<button
key={key}
data-testid={`${testIdPrefix}-${key}`}
data-tab-active={active ? "true" : undefined}
role="tab"
aria-selected={active}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
active
? "bg-accent/15 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{label}</span>
{count !== null && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
}`}
>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Right edge gradient + chevron */}
<div
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
canScrollRight ? "opacity-100" : "opacity-0"
}`}
/>
{canScrollRight && (
<button
onClick={() => scrollByButton(1)}
aria-label="scroll tabs right"
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
);
}
@@ -0,0 +1,102 @@
/**
* @file ConfirmDeleteModal.tsx
* @description Confirmation modal for deleting a config artifact. Tool-
* agnostic — the caller supplies the async delete action; the modal only
* tracks the busy state and Escape-to-cancel behavior.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, Trash2 } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
/** See EditorModal.tsx's `DefaultScope` — same reasoning: Codex instantiates
* `TScope` as `"user" | "project"` instead of casting into Claude's wider
* default. */
export type ConfirmDeleteState<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
> = {
type: TType;
scope: TScope;
name?: string;
path: string;
project?: string; // set for type === "auto-memory"
} | null;
interface ConfirmDeleteModalProps<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
> {
state: NonNullable<ConfirmDeleteState<TType, TScope>>;
onCancel: () => void;
onConfirm: () => Promise<void>;
}
export function ConfirmDeleteModal<
TType extends string = CcArtifactType,
TScope extends string = "user" | "project" | "auto-memory",
>({ state, onCancel, onConfirm }: ConfirmDeleteModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const [busy, setBusy] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const handleConfirm = useCallback(async () => {
setBusy(true);
try {
await onConfirm();
} finally {
setBusy(false);
}
}, [onConfirm]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onCancel}
>
<div
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-status-danger" />
<span className="text-sm font-medium text-fg-primary flex-1">
{t("edit.confirmDelete")}
</span>
</div>
<div className="p-4 space-y-3">
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
{t("edit.confirmDeletePath", { path: state.path })}
</div>
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onCancel}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
>
{t("edit.cancel")}
</button>
<button
onClick={handleConfirm}
disabled={busy}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="w-3.5 h-3.5" />
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,276 @@
/**
* @file EditorModal.tsx
* @description Create/edit modal for text-file config artifacts (skills,
* agents, commands, output styles, memory). Tool-agnostic: it takes a
* `fetchContent` callback instead of reaching for any particular tool's API,
* so the same modal serves both the Claude Code and Codex config pages.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, X, Save, AlertCircle } from "lucide-react";
import type { CcArtifactType } from "../../lib/api";
/** Claude Code's own scope union, kept as the default so existing callers
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
* its caller instantiates `TScope` as just `"user" | "project"` instead of
* casting its own narrower type into this one. */
type DefaultScope = "user" | "project" | "auto-memory";
export type EditorState<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
> =
| {
mode: "create";
type: TType;
defaultScope: "user" | "project";
template: string;
project?: string; // set for type === "auto-memory"
}
| {
mode: "edit";
type: TType;
scope: TScope;
name: string;
filePath: string;
project?: string; // set for type === "auto-memory"
}
| null;
/** Types that are a single fixed file per scope (e.g. Claude's CLAUDE.md, Codex's
* AGENTS.md / config.toml) - no `name` field, and no artifact name is ever sent. */
const DEFAULT_NAMELESS_TYPES = ["memory"];
interface EditorModalProps<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
> {
state: NonNullable<EditorState<TType, TScope>>;
fetchContent: (filePath: string) => Promise<string>;
onClose: () => void;
onSave: (args: {
type: TType;
targetScope: TScope;
name: string | undefined;
content: string;
project?: string;
}) => Promise<void>;
/** Which `state.type` values are nameless singleton files. Defaults to
* Claude Code's own `["memory"]` so existing callers are unaffected. */
namelessTypes?: readonly string[];
}
export function EditorModal<
TType extends string = CcArtifactType,
TScope extends string = DefaultScope,
>({
state,
fetchContent,
onClose,
onSave,
namelessTypes = DEFAULT_NAMELESS_TYPES,
}: EditorModalProps<TType, TScope>) {
const { t } = useTranslation("ccConfig");
const isCreate = state.mode === "create";
const isNameless = namelessTypes.includes(state.type);
const isAutoMemory = state.type === "auto-memory";
const [content, setContent] = useState<string>(isCreate ? state.template : "");
const [name, setName] = useState<string>("");
const [targetScope, setTargetScope] = useState<"user" | "project">(
isCreate
? state.defaultScope
: state.scope === "auto-memory"
? "user"
: (state.scope as "user" | "project")
);
const [loading, setLoading] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// For edit mode, fetch the actual file content
useEffect(() => {
if (state.mode === "edit") {
setLoading(true);
fetchContent(state.filePath)
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "unknown";
setError(msg);
setLoading(false);
});
}
}, [state, fetchContent]);
// Esc to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
try {
if (!isNameless && state.mode === "create" && !name) {
setError(t("edit.nameLabel"));
setSaving(false);
return;
}
// For auto-memory creates, append a .md extension when the user omits it.
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
const effectiveName =
state.mode === "edit" ? state.name : isNameless ? undefined : createName;
await onSave({
type: state.type,
// Widening back to TScope: this component is the one place that
// legitimately knows about all three scope shapes across both
// callers; a Codex caller's TScope ("user" | "project") never
// actually receives "auto-memory" since isAutoMemory can only be
// true when TType includes "auto-memory" (Claude-only).
targetScope: (isAutoMemory ? "auto-memory" : targetScope) as TScope,
name: effectiveName,
content,
project: state.project,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("edit.writeError", { message: msg }));
} finally {
setSaving(false);
}
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
const titleText = isCreate
? isAutoMemory
? t("memory.newFileTitle", { project: state.project ?? "" })
: t("edit.newTitle", { type: state.type })
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Pencil className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
{isCreate && !isNameless && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.nameLabel")}
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
}
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<p className="mt-1 text-[10px] text-fg-muted">
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
</p>
</div>
{isAutoMemory ? (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("memory.projectLabel")}
</label>
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
{state.project}
</div>
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
</div>
) : (
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.scopePicker")}
</label>
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
{(["user", "project"] as const).map((s) => (
<button
key={s}
onClick={() => setTargetScope(s)}
className={`px-3 py-1 text-[11px] font-medium rounded ${
targetScope === s
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`scope.${s}`)}
</button>
))}
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{t("edit.contentLabel")}
</label>
{loading ? (
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
) : (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
/>
)}
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("edit.cancel")}
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("edit.saving") : t("edit.save")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
/**
* @file FileViewer.tsx
* @description Read-only file-content modal for the config explorer. Takes
* already-fetched data as props (path/data/error) rather than fetching
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
* config page) owns the fetch.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FileText, X, AlertCircle } from "lucide-react";
import type { CcFileResponse } from "../../lib/api";
import { CopyButton } from "./configPrimitives";
export function FileViewer({
state,
onClose,
}: {
state: { path: string; data: CcFileResponse | null; error: string | null };
onClose: () => void;
}) {
const { t } = useTranslation("ccConfig");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<FileText className="w-4 h-4 text-fg-muted" />
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
{state.path}
</span>
<CopyButton value={state.path} />
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4">
{state.error ? (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{state.error}
</div>
) : !state.data ? (
<div className="text-sm text-fg-muted">…</div>
) : (
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
{state.data.text}
{state.data.truncated && (
<span className="text-fg-muted italic">
{"\n\n"}
{t("common.truncated")}
</span>
)}
</pre>
)}
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* @file MdItemList.tsx
* @description Generic list + card for markdown-frontmatter config artifacts
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
* items and the open/edit/delete handlers, nothing here reaches for an API.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api";
import { ScopeBadge } from "./ScopeBadge";
import { Empty, SkeletonRows } from "./configPrimitives";
interface MdItemListProps {
items: CcMdItem[] | null;
search: string;
onOpen: (path: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
const filtered = useMemo(() => {
if (!items) return null;
const q = search.toLowerCase();
return items.filter((it) => {
if (!q) return true;
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
.filter(Boolean)
.join(" ")
.toLowerCase();
return blob.includes(q);
});
}, [items, search]);
if (!filtered) return <SkeletonRows n={6} />;
if (filtered.length === 0) return <Empty />;
return (
<div className="space-y-2">
{filtered.map((it) => (
<MdItemCard
key={`${it.scope}:${it.name}`}
item={it}
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
kind={kind}
/>
))}
</div>
);
}
interface MdItemCardProps {
item: CcMdItem;
onOpen: (p: string) => void;
onEdit: (
type: CcArtifactType,
item: { scope: "user" | "project"; name: string; filePath: string }
) => void;
onDelete: (
type: CcArtifactType,
scope: "user" | "project",
name: string | undefined,
path: string
) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
const { t } = useTranslation("ccConfig");
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
const filePath = item.file || `${item.path}/SKILL.md`;
const description =
item.frontmatter.description ||
item.preview
.replace(/^#+\s.*\n/, "")
.trim()
.slice(0, 200);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
<ScopeBadge scope={item.scope} />
{item.frontmatter.model && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
{item.frontmatter.model}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
{description}
</p>
)}
{kind === "agents" && item.frontmatter.tools && (
<div className="mt-2 text-[11px] text-fg-muted">
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
</div>
)}
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
</div>
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button
onClick={() => onOpen(filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<ExternalLink className="w-3 h-3" />
{t("common.viewSource")}
</button>
<button
onClick={() => onEdit(artifactType, { scope: item.scope, name: item.name, filePath })}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
>
<Pencil className="w-3 h-3" />
{t("edit.editButton")}
</button>
<button
onClick={() => onDelete(artifactType, item.scope, item.name, filePath)}
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
>
<Trash2 className="w-3 h-3" />
{t("edit.deleteButton")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,180 @@
/**
* @file ProjectMemoryModal.tsx
* @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a
* directory that is not the dashboard's own. Shared by the Claude Code and
* Codex config pages; the caller supplies its i18n namespace and the create
* call, so this component knows about neither tool.
*
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
* offered as suggestions on a plain text input rather than a closed dropdown:
* a project you have never run through the dashboard is exactly the case this
* modal exists for, so it must stay typeable.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
ns: string;
/** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */
filename: string;
/** Starting body text, so the user edits a skeleton instead of a blank box. */
template: string;
onClose: () => void;
/** Resolves with the path actually written (the server realpaths the dir). */
onCreate: (args: { dir: string; content: string }) => Promise<string>;
}
export function ProjectMemoryModal({
ns,
filename,
template,
onClose,
onCreate,
}: ProjectMemoryModalProps) {
// Every key lives under `projectMemory.*` in BOTH namespaces, so this
// component never depends on the calling page's other key groups lining up.
const { t } = useTranslation(ns);
const listId = useId();
const [dir, setDir] = useState("");
const [content, setContent] = useState(template);
const [suggestions, setSuggestions] = useState<CwdSuggestion[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.run
.cwds()
.then((r) => setSuggestions(r.items))
// Suggestions are a convenience; the input still works without them.
.catch(() => undefined);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCreate = useCallback(async () => {
const target = dir.trim();
if (!target) {
setError(t("projectMemory.dirRequired"));
return;
}
setSaving(true);
setError(null);
try {
await onCreate({ dir: target, content });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
} finally {
setSaving(false);
}
}, [dir, content, onCreate, t]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<BookOpen className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
<div>
<label
htmlFor={`${listId}-dir`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.dirLabel")}
</label>
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.path} value={s.path}>
{s.label}
</option>
))}
</datalist>
<p className="mt-1 text-[10px] text-fg-muted">
{t("projectMemory.dirHelp", { file: filename })}
</p>
</div>
<div>
<label
htmlFor={`${listId}-content`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.contentLabel")}
</label>
<textarea
id={`${listId}-content`}
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
/>
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("projectMemory.cancel")}
</button>
<button
onClick={handleCreate}
disabled={saving}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
/**
* @file ScopeBadge.tsx
* @description Small colored badge for a config artifact's scope (user /
* project / project-local). Tool-agnostic — takes a plain string so it works
* for Claude Code and Codex scopes alike.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
export function ScopeBadge({ scope }: { scope: string }) {
const { t } = useTranslation("ccConfig");
const color =
scope === "user"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: scope === "project"
? "bg-status-success/10 text-status-success border-status-success/30"
: scope === "project-local"
? "bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30"
: "bg-surface-3 text-fg-secondary border-border";
const label =
scope === "project-local"
? t("scope.projectLocal")
: scope === "user"
? t("scope.user")
: scope === "project"
? t("scope.project")
: scope;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
);
}
@@ -0,0 +1,46 @@
/**
* @file ToastNotice.tsx
* @description Bottom-right success/error toast for the config explorer.
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
* message down.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { AlertCircle, Check, X } from "lucide-react";
export type Toast = { kind: "success" | "error"; message: string } | null;
export function ToastNotice({
toast,
onDismiss,
}: {
toast: NonNullable<Toast>;
onDismiss: () => void;
}) {
const isErr = toast.kind === "error";
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md">
<div
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
isErr
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-status-success/40 bg-status-success/10 text-status-success"
}`}
>
{isErr ? (
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
) : (
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
)}
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
<button
onClick={onDismiss}
className="text-current/70 hover:text-current p-0.5"
aria-label="dismiss"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
/**
* @file EditorModal.test.tsx
* @description Unit tests for the tool-agnostic `EditorModal`: it must load
* existing content through the injected `fetchContent` callback rather than
* a hardcoded API, and must refuse to save a create with no name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorModal } from "../EditorModal";
describe("EditorModal", () => {
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
const fetchContent = vi.fn().mockResolvedValue("old body");
render(
<EditorModal
state={{
mode: "edit",
type: "rules",
scope: "user",
name: "default",
filePath: "/r/default.rules",
}}
fetchContent={fetchContent}
onClose={() => {}}
onSave={async () => {}}
/>
);
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
});
it("refuses to save a create with no name", async () => {
const onSave = vi.fn();
render(
<EditorModal
state={{ mode: "create", type: "agents", defaultScope: "user", template: "" }}
fetchContent={async () => ""}
onClose={() => {}}
onSave={onSave}
/>
);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,98 @@
/**
* @file ProjectMemoryModal.test.tsx
* @description Unit tests for `ProjectMemoryModal`: it must send the directory
* the user typed (not a suggestion it picked for them), must refuse to submit
* an empty one, and must render its labels from whichever namespace the
* calling page passes — the Codex page shares this component with the Claude
* page, and a key group that only exists in `ccConfig` would render raw.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProjectMemoryModal } from "../ProjectMemoryModal";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { run: { cwds: vi.fn() } },
}));
const cwds = vi.mocked(api.run.cwds);
beforeEach(() => {
cwds.mockResolvedValue({
items: [{ kind: "recent", path: "/home/me/known-project", label: "known-project" }],
});
});
function renderModal(over: Partial<Parameters<typeof ProjectMemoryModal>[0]> = {}) {
const onCreate = vi.fn().mockResolvedValue("/home/me/typed/CLAUDE.md");
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template="# Project memory"
onClose={() => {}}
onCreate={onCreate}
{...over}
/>
);
return { onCreate };
}
describe("ProjectMemoryModal", () => {
it("sends the typed directory and the edited content", async () => {
const { onCreate } = renderModal();
await userEvent.type(screen.getByLabelText(/project directory/i), "/home/me/typed");
const body = screen.getByLabelText(/content/i);
await userEvent.clear(body);
await userEvent.type(body, "hello");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).toHaveBeenCalledWith({ dir: "/home/me/typed", content: "hello" });
});
it("refuses to submit without a directory", async () => {
const { onCreate } = renderModal();
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).not.toHaveBeenCalled();
expect(await screen.findByText(/directory is required/i)).toBeInTheDocument();
});
it("offers the dashboard's known projects as suggestions, not as the only choice", async () => {
renderModal();
// A datalist suggests; it does not constrain. A project the dashboard has
// never run in is precisely this modal's reason to exist, so the input
// stays free text.
const input = screen.getByLabelText(/project directory/i);
expect(input.tagName).toBe("INPUT");
// jsdom exposes no accessible role for datalist options, so query the DOM.
await vi.waitFor(() => {
const list = document.getElementById(input.getAttribute("list")!);
expect(list?.querySelector('option[value="/home/me/known-project"]')).not.toBeNull();
});
});
it("renders from the Codex namespace too", async () => {
renderModal({ ns: "codexConfig", filename: "AGENTS.md" });
expect(await screen.findByText(/Create AGENTS\.md for another project/i)).toBeInTheDocument();
// A missing key renders as the bare key path — catch that directly.
expect(screen.queryByText(/projectMemory\./)).not.toBeInTheDocument();
});
it("shows the server's rejection instead of closing", async () => {
const onCreate = vi.fn().mockRejectedValue(new Error("CLAUDE.md already exists in /p"));
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template=""
onClose={() => {}}
onCreate={onCreate}
/>
);
await userEvent.type(screen.getByLabelText(/project directory/i), "/p");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(await screen.findByText(/already exists in \/p/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
/**
* @file configPrimitives.tsx
* @description Small tool-agnostic atoms shared by the config-explorer UI
* (Claude Code today, Codex next): an empty-state card, a skeleton-row list
* placeholder, a copy-to-clipboard button, and a byte-size formatter. None
* of these know about any particular tool's API.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Check } from "lucide-react";
export function CopyButton({ value }: { value: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("common.copyPath")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
);
}
export function Empty() {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("common.empty")}
</div>
);
}
export function SkeletonRows({ n }: { n: number }) {
return (
<div className="space-y-2">
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
))}
</div>
);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
@@ -320,7 +320,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={linkM[2]!}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
</a>
@@ -337,7 +337,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={urlM[0]}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
>
{urlM[0]}
</a>
@@ -428,7 +428,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
);
}
return (
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
<ul
key={idx}
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
{renderListItem(item, `li${idx}-${i}`)}
@@ -82,25 +82,25 @@ const SENDER_STYLES: Record<
label: "User",
icon: User,
avatarRing:
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30",
accentBar: "before:bg-blue-600/40",
headerText: "text-blue-300",
headerText: "text-blue-700 dark:text-blue-300",
},
assistant: {
label: "Assistant",
icon: Bot,
avatarRing:
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30",
accentBar: "before:bg-violet-500/40",
headerText: "text-violet-200",
headerText: "text-violet-800 dark:text-violet-200",
},
orchestrator: {
label: "Main agent",
icon: Workflow,
avatarRing:
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30",
accentBar: "before:bg-teal-500/40",
headerText: "text-teal-200",
headerText: "text-teal-800 dark:text-teal-200",
},
system: {
label: "System",
@@ -169,7 +169,7 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
return (
<div className="flex items-center justify-center py-1">
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
<Pencil className="w-3 h-3 text-violet-700/70 dark:text-violet-300/70 flex-shrink-0" />
<span className="text-fg-muted">Renamed session →</span>
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
{timestamp && (
@@ -253,11 +253,13 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
<CollapsibleBlock
key={key}
text={seg.text}
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
icon={
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
}
title="Persisted output"
borderClass="border-violet-500/20"
bgClass="bg-violet-500/5"
textClass="text-violet-300/80"
textClass="text-violet-700/80 dark:text-violet-300/80"
/>
);
case "text": {
@@ -394,7 +396,9 @@ export function MessageList({ messages, loading }: MessageListProps) {
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span className="text-fg-muted">·</span>
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
<span className="text-orange-700/70 dark:text-orange-300/70">
↑ {fmt(msg.usage.output_tokens)}
</span>
</span>
)}
{msg.timestamp && (
@@ -413,11 +417,13 @@ export function MessageList({ messages, loading }: MessageListProps) {
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-cyan-400/60 flex-shrink-0" />}
icon={
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
}
title="Task Notification"
borderClass="border-cyan-500/20"
bgClass="bg-cyan-500/5"
textClass="text-cyan-400/80"
textClass="text-cyan-700/80 dark:text-cyan-400/80"
/>
);
}
@@ -156,7 +156,7 @@ function renderInput(toolUse: TranscriptContent) {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
<FileText className="w-3.5 h-3.5 text-violet-400" />
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<span className="font-mono">{obj.file_path}</span>
{obj.replace_all === true && (
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
@@ -178,7 +178,7 @@ function renderInput(toolUse: TranscriptContent) {
if (tool === "read" && typeof obj.file_path === "string") {
return (
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
<span className="font-mono break-all">{obj.file_path}</span>
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
@@ -198,7 +198,7 @@ function renderInput(toolUse: TranscriptContent) {
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Pattern
</span>
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
{obj.pattern}
</code>
</div>
+30 -30
View File
@@ -83,8 +83,8 @@ export interface ToolStyle {
const VIOLET: ToolStyle = {
Icon: Wrench,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
text: "text-violet-700 dark:text-violet-300",
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
};
@@ -92,99 +92,99 @@ const VIOLET: ToolStyle = {
const STYLES: Record<string, ToolStyle> = {
bash: {
Icon: Terminal,
text: "text-emerald-300",
chip: "bg-emerald-500/15 text-emerald-300",
text: "text-emerald-700 dark:text-emerald-300",
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
bar: "bg-emerald-500/60",
border: "border-emerald-500/20",
},
read: {
Icon: FileText,
text: "text-sky-300",
chip: "bg-sky-500/15 text-sky-300",
text: "text-sky-700 dark:text-sky-300",
chip: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
bar: "bg-sky-500/60",
border: "border-sky-500/20",
},
write: {
Icon: FilePlus2,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
text: "text-violet-700 dark:text-violet-300",
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
},
edit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
text: "text-amber-700 dark:text-amber-300",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
multiedit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
text: "text-amber-700 dark:text-amber-300",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
grep: {
Icon: Search,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
text: "text-cyan-700 dark:text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
glob: {
Icon: FolderTree,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
text: "text-cyan-700 dark:text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
webfetch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
text: "text-blue-700 dark:text-blue-300",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
websearch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
text: "text-blue-700 dark:text-blue-300",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
task: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
text: "text-pink-700 dark:text-pink-300",
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
agent: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
text: "text-pink-700 dark:text-pink-300",
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
todowrite: {
Icon: ListTodo,
text: "text-rose-300",
chip: "bg-rose-500/15 text-rose-300",
text: "text-rose-700 dark:text-rose-300",
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
bar: "bg-rose-500/60",
border: "border-rose-500/20",
},
schedulewakeup: {
Icon: Clock,
text: "text-orange-300",
chip: "bg-orange-500/15 text-orange-300",
text: "text-orange-700 dark:text-orange-300",
chip: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
bar: "bg-orange-500/60",
border: "border-orange-500/20",
},
skill: {
Icon: Sparkles,
text: "text-fuchsia-300",
chip: "bg-fuchsia-500/15 text-fuchsia-300",
text: "text-fuchsia-700 dark:text-fuchsia-300",
chip: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300",
bar: "bg-fuchsia-500/60",
border: "border-fuchsia-500/20",
},
@@ -289,7 +289,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
let newLine = hunk.newStart;
return (
<div>
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
<div className="px-3 py-1 text-[10px] text-cyan-700 dark:text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</div>
<table className="w-full border-collapse">
@@ -301,7 +301,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
const showNew = kind !== "remove";
const rowBg =
kind === "add"
? "bg-green-500/10 text-green-200"
? "bg-green-500/10 text-green-800 dark:text-green-200"
: kind === "remove"
? "bg-status-danger/10 text-status-danger"
: "text-fg-secondary";
@@ -372,7 +372,7 @@ function ValueCell({ value }: { value: unknown }) {
<span
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10"
}`}
>
@@ -445,7 +445,7 @@ export function MatchList({ matches }: { matches: GrepMatch[] }) {
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
{matches.map((m, i) => (
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
{m.file && <span className="text-cyan-300">{m.file}</span>}
{m.file && <span className="text-cyan-700 dark:text-cyan-300">{m.file}</span>}
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
</li>
+378 -88
View File
@@ -24,8 +24,8 @@ import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool } from "../../lib/types";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -77,11 +77,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[];
@@ -92,9 +97,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 [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
// "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 [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
const [busy, setBusy] = useState(false);
@@ -104,6 +117,7 @@ export function AddLaneModal({
profile: "scaffolded" | "skipped" | "failed";
agents: "ok" | "failed";
mcp: "ok" | "failed";
router: "ok" | "skipped" | "failed";
} | null>(null);
const reset = () => {
@@ -112,8 +126,11 @@ export function AddLaneModal({
setTitle("");
setBranch("");
setBranches(null);
setWorktrees([]);
setBranchMode("new");
setBase("");
setPipeline("default");
setRouter("native");
setRoutersLoaded(false);
setTool("claude");
setBranchesError(null);
setError(null);
@@ -121,22 +138,42 @@ 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()
api.aiRouters
.list()
.then((r) => {
if (!cancelled) setPipelines(r.pipelines);
if (cancelled) return;
setRouters(r.items);
// A profile deleted in another tab while this modal sat open would
// otherwise stay selected in state while the select showed no matching
// option — and submit would send a name the server no longer knows.
setRouter((current) =>
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
);
})
.catch(() => {
// Quiet: the select just falls back to the single `default` option
// below, and the lane still gets created.
if (!cancelled) setPipelines([]);
// Quiet: with no list the radio simply offers Native, and the lane is
// still created — a router is an optional extra, never a precondition.
if (!cancelled) setRouters([]);
})
.finally(() => {
if (!cancelled) setRoutersLoaded(true);
});
return () => {
cancelled = true;
@@ -153,6 +190,7 @@ export function AddLaneModal({
const path = sourceRepo.trim();
if (!path) {
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(null);
return;
@@ -163,6 +201,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 {
@@ -170,6 +209,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"));
}
@@ -177,6 +217,10 @@ export function AddLaneModal({
return () => window.clearTimeout(timer);
}, [mode, sourceRepo, t]);
// 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);
const submit = async () => {
const repo = sourceRepo.trim();
const name = title.trim();
@@ -189,10 +233,40 @@ 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
// of those failures skip `onAdded` would leave a real lane invisible in
// 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;
}
// 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(routerErr instanceof Error ? routerErr.message : String(routerErr));
setBusy(false);
return;
}
reset();
onClose();
} catch (err) {
@@ -202,14 +276,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);
@@ -226,12 +303,13 @@ export function AddLaneModal({
return;
}
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
api.lanes.profileInit(result.lane.id),
api.lanes.agentsInstall(result.lane.id),
api.lanes.mcpSync(result.lane.id),
applyRouter(result.lane.id),
]);
setSetupResult({
const outcome = {
profile:
profileOutcome.status === "fulfilled"
? profileOutcome.value.scaffolded
@@ -240,7 +318,11 @@ export function AddLaneModal({
: "failed",
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
});
router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
} as const;
const anyFailed = Object.values(outcome).includes("failed");
setSetupResult(outcome);
if (import.meta.env.DEV) {
console.info("[add-lane] auto-setup result:", {
@@ -248,13 +330,23 @@ export function AddLaneModal({
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
// Never the profile itself: it would put an endpoint and a lane id in
// the browser console. (The key is never sent here at all.)
router: routerOutcome.status,
});
}
// 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);
@@ -279,21 +371,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?.length || (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">
@@ -356,6 +460,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")}
@@ -365,74 +500,224 @@ 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>
{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 ? (
// No commit, nothing to fork: the server refuses this with
// EEMPTYREPO, so the one way forward is offered instead.
<div className="space-y-1">
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
<button
type="button"
onClick={() => setMode("repo")}
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
>
{t("addLaneNoBranchesUseRepo")}
</button>
</div>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="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)}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="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>
@@ -445,6 +730,11 @@ export function AddLaneModal({
},
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
{
label: t("addLaneSetupRouter"),
ok: setupResult.router !== "failed",
skipped: setupResult.router === "skipped",
},
] as const
).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
@@ -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)) : [];
@@ -99,7 +99,7 @@ export function FolderBrowseModal({
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
+199 -143
View File
@@ -1,77 +1,48 @@
/**
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
* @file One lane's card: CI/PR facts, the "needs you"
* banner sourced from Claude Code's Notification hook, and (for managed
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
* DestructiveLaneModal. A dead lane (its driving session went silent while
* it should have been working) is called out loudly — that is the failure
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
* NOT repeated here — this card only ever renders inside the Workspace
* lane-detail section, whose header above it already shows them. The repo
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
* lane-detail section, whose header above it already shows them. Neither is
* progress: the pipeline map directly below this card draws the same number
* as named, coloured nodes, so a bar and a percent beside it were a worse
* 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 — 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.
* card's own scroll area. The working copy — branch, uncommitted counts, the
* git action bar — is not here either: it heads the Changes panel
* (`LaneGitHeader`), beside the diff it describes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
import type { AiRouter } from "../../lib/api";
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
* each call is three git subprocesses server-side, and a branch name does not
* change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;
/** How often a mounted card re-probes its stack. Faster than the git refresh
* because a stack dying is exactly what the user needs to see, and slower than
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
* git refresh because a stack dying is exactly what the user needs to see, and slower than
* the lane poll because each call opens a socket per declared port. */
const RUNTIME_REFRESH_MS = 10_000;
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
* as the git facts (slow, since lock state changes infrequently). */
* as the Changes panel's git facts (slow, since lock state changes infrequently). */
const LOCKS_REFRESH_MS = 30_000;
/**
* The lane's own working copy, fetched per card rather than folded into the
* 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 {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
.catch(() => {
// Silent: a card that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip.
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId]);
return facts;
}
/**
* The lane's own application stack. Same shape and same silence as the git
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
* is the common case and not an error worth a banner.
*
* `bump` lets an up/down action re-read immediately instead of waiting out the
@@ -134,38 +105,156 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks;
}
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
/**
* Which AI Router this lane runs against, and the one place it can be changed
* after the lane exists — the Add-lane modal is the only other one, and a lane
* outlives the moment it was created.
*
* Applying writes the lane's own config on disk as well as the DB column, so it
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
* the select back: the row must not claim a router the lane did not get.
*
* Rendered only when there is something to choose. A user who has never opened
* the AI Router tab has exactly the built-in `native` profile, and a select
* with one option is a control that cannot do anything.
*/
export function LaneRouterRow({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
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; runId?: string } | null>(null);
function useLaneIntegrations(
laneId: number,
available: boolean
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
const [state, setState] = useState<Record<string, boolean> | 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.
useEffect(() => {
setCurrent(lane.router_profile || "native");
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(() => {
if (!available) {
setState(null);
return;
}
let alive = true;
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
.then((results) => {
if (!alive) return;
const next: Record<string, boolean> = {};
INTEGRATION_NAMES.forEach((name, i) => {
next[name] = results[i]?.enabled ?? false;
});
setState(next);
api.aiRouters
.list()
.then((r) => {
if (alive) setRouters(r.items);
})
.catch(() => {
if (alive) setState(null);
// Silent, same contract as the runtime poller above: a card that
// cannot list routers simply shows no router row.
if (alive) setRouters([]);
});
return () => {
alive = false;
};
}, [laneId, available]);
}, []);
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
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 {
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);
}
};
return (
<label
className="flex items-center gap-1.5 text-xs text-fg-muted"
data-testid={`lane-router-${lane.id}`}
>
{t("router.label")}
<select
value={current}
disabled={busy}
onChange={(e) => void change(e.target.value)}
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
>
{routers.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
{/* The applied-note is a tooltip, not a line of its own: it fires on a
click the user just made, and a wrapped sentence under the control
would push every row below it down for the life of the selection.
A failure still gets visible colour, because that one is not
self-evident from the select. */}
{note && (
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
{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>
);
}
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
@@ -182,7 +271,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
@@ -191,14 +281,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);
@@ -218,6 +308,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);
@@ -273,6 +376,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"
@@ -333,10 +452,8 @@ export default function LaneCard({
const [menuOpen, setMenuOpen] = useState(false);
const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
/**
* Live boot feedback. A build can run for minutes, and a card showing only a
@@ -363,25 +480,10 @@ export default function LaneCard({
<>
<div
data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
// Every row below is conditional: a lane with nothing to report
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
>
{/* Progress line: how far through the pipeline. The stage's own name
and how long it's been running are already in the Workspace
header above; not repeated here. Omitted at 0% — an empty bar
is not information, just a blank row above a dead/idle lane. */}
{lane.progress > 0 && (
<div className="mb-1.5 flex items-center gap-2 text-xs">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
data-testid="lane-progress-fill"
className="h-full rounded-full bg-blue-600 transition-[width]"
style={{ width: `${lane.progress}%` }}
/>
</div>
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
</div>
)}
{(lane.ci_status || lane.tool === "codex") && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
{lane.ci_status && (
@@ -397,11 +499,8 @@ export default function LaneCard({
</div>
)}
{lane.needs_action && (
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
⚠ {lane.needs_action}
</div>
)}
{/* No needs-you banner here: the strip card already flags it, and the
console directly below is where the prompt actually waits. */}
{/* Runtime strip: the lane's own app stack, shown only for lanes that
declare a profile. A port that drifted from `base + slot` is called
@@ -477,31 +576,6 @@ export default function LaneCard({
</div>
)}
<dl className="mb-2 space-y-0.5 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
<div className="truncate">
⑂ {git.branch}
<span className="ml-2 text-fg-muted">{git.head}</span>
</div>
<div className="truncate text-fg-muted" title={git.subject}>
{git.subject}
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<div className="text-status-warning/80">
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</div>
)}
</div>
)}
{/* 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 && <div className="truncate">⑂ {lane.branch}</div>}
<div className="truncate text-fg-muted" title={lane.cwd}>
{lane.cwd}
</div>
</dl>
{childWorktrees && childWorktrees.length > 0 && (
<div
data-testid="lane-child-worktrees"
@@ -516,7 +590,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
@@ -527,24 +601,6 @@ export default function LaneCard({
</div>
)}
{integrations && (
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
<span
key={name}
data-testid={`lane-integration-${name}`}
className={`rounded-full px-2 py-0.5 ${
integrations[name]
? "bg-status-success/10 text-status-success"
: "bg-surface-2 text-fg-muted"
}`}
>
{t(`integrations.${name}`)}
</span>
))}
</div>
)}
{/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. Repo tooling and the
remove button live in the Workspace page header/pipeline row now —
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,796 @@
/**
* @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.
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useContext,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
export type DiffLayout = "unified" | "split";
/**
* How tall a diff box may be. `fill` (the single-file view) takes the height
* its parent gives it. In the review list, which stacks many files, a diff
* grows to its full height so the PANEL is the one vertical scroller — a
* 60vh box per file nested inside it made hunk navigation scroll two boxes
* at once and land nowhere predictable. Only a windowed diff (above
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
* scroller.
*/
function boxHeight(fill: boolean, windowed: boolean) {
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
}
/**
* Bring `el` to the top of the scroller that actually scrolls it, below
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
* ancestor, the page included.
*/
function scrollToTop(el: HTMLElement, pad: number) {
let s = el.parentElement;
while (
s &&
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
)
s = s.parentElement;
if (!s) return;
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
}
/** Rows above which a view windows; at or below it renders exactly as before. */
export const WINDOW_THRESHOLD = 500;
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
export const ROW_PX = 16;
const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX };
/**
* The `[start, end)` item range to render for a scroll position. `scrollTop`
* is clamped to the content first, so a stale offset (content that shrank, a
* layout switch) still lands on real rows instead of an empty window.
*/
export function visibleRange(
scrollTop: number,
viewport: number,
rowHeight: number,
count: number,
overscan: number
) {
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
return { start: Math.min(start, end), end };
}
/**
* Windowing state for one scroller of `count` items. Off (the whole range)
* at or below the threshold.
*/
function useWindow(count: number) {
const [top, setTop] = useState(0);
const on = count > WINDOW_THRESHOLD;
// The offset is tracked even while windowing is off, so a diff that GROWS
// past the threshold (expanded context) windows where the reader is, not at
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
const lastTop = useRef(0);
// count 0 = no scroller here (split layout): the browser drops its offset,
// so coming back to unified must start from 0, not the pre-split row.
if (count === 0) lastTop.current = 0;
const [wasOn, setWasOn] = useState(on);
if (wasOn !== on) {
setWasOn(on);
setTop(on ? lastTop.current : 0);
}
// ponytail: the viewport is taken as the window height — never smaller than
// a box that lives inside the window, so it only over-renders a few dozen
// rows, and it needs no ResizeObserver to follow a panel resize.
const range = on
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
: { start: 0, end: count };
const onScroll = (el: HTMLElement) => {
lastTop.current = el.scrollTop;
if (on) setTop(el.scrollTop);
};
return { on, range, onScroll };
}
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
function longest(texts: Iterable<string>) {
let max = 0;
for (const t of texts) {
let n = t.length;
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
if (n > max) max = n;
}
return max;
}
/** Stands in for `rows` off-screen rows. */
function Spacer({ rows }: { rows: number }) {
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
}
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
function Windowed({
count,
range,
minWidth,
children,
}: {
count: number;
range: { start: number; end: number };
minWidth: string;
children: React.ReactNode;
}) {
return (
<div data-diff-content style={{ minWidth }}>
<Spacer rows={range.start} />
{children}
<Spacer rows={count - range.end} />
</div>
);
}
/** 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],
};
}
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
type EmphPair = { del: [number, number]; add: [number, number] };
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
* index in `hunk.lines` (what the unified view renders by). */
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
// columns and every re-render of the same data share one pass. A refetch
// brings new objects and the old entries go with them.
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
/** Pair each run of removed lines with the run of added lines following it,
* carrying indices so emphasis lands without searching the hunk again. */
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
const hit = analysed.get(hunk);
if (hit) return hit;
const lines = hunk.lines;
const rows: PairRow[] = [];
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
rows.push({ left: line, right: line, emph: null });
i += 1;
continue;
}
const delStart = i;
while (i < lines.length && lines[i]!.type === "del") i += 1;
const addStart = i;
while (i < lines.length && lines[i]!.type === "add") i += 1;
const dels = addStart - delStart;
const adds = i - addStart;
for (let k = 0; k < Math.max(dels, adds); k += 1) {
const left = k < dels ? lines[delStart + k]! : null;
const right = k < adds ? lines[addStart + k]! : null;
const e = left && right ? emphasis(left.text, right.text) : null;
if (e) {
emph.set(delStart + k, e.del);
emph.set(addStart + k, e.add);
}
rows.push({ left, right, emph: e });
}
}
const out = { rows, emph };
analysed.set(hunk, out);
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.
* `style` pins a windowed row's height; unwindowed rows carry none. */
function Row({
tint,
style,
children,
}: {
tint: string;
style?: CSSProperties;
children: React.ReactNode;
}) {
return (
<div
className={`flex w-max min-w-full ${tint}`}
style={style}
data-diff-item={style ? "" : undefined}
>
{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";
}
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
* last changed line. A context rather than a prop because lines render from
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (hunk: LaneDiffHunk) => ReactNode;
hunks: LaneDiffHunk[];
go: (index: number) => void;
} | null>(null);
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
function lastChangedLine(hunk: LaneDiffHunk) {
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
if (hunk.lines[i]!.type !== "ctx") return i;
}
return -1;
}
function lastChangedRow(hunk: LaneDiffHunk) {
const rows = analyseHunk(hunk).rows;
for (let i = rows.length - 1; i >= 0; i -= 1) {
const r = rows[i]!;
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
}
return -1;
}
/** The review bar: hangs just BELOW the last changed line, pinned to the
* visible right edge, so it sits on the change it acts on — the editor-style
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
const index = ctx.hunks.indexOf(hunk);
const total = ctx.hunks.length;
const nav =
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
return (
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
<span
data-testid="lane-hunk-bar"
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
>
<button
type="button"
aria-label={t("changes.prevHunk")}
title={t("changes.prevHunk")}
disabled={index <= 0}
onClick={() => ctx.go(index - 1)}
className={nav}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
aria-label={t("changes.nextHunk")}
title={t("changes.nextHunk")}
disabled={index >= total - 1}
onClick={() => ctx.go(index + 1)}
className={nav}
>
<ChevronDown className="h-3 w-3" />
</button>
<span className="px-0.5 tabular-nums text-fg-muted">
{t("changes.hunkOf", { n: index + 1, total })}
</span>
{ctx.actions(hunk)}
</span>
</span>
);
}
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
const ctx = useContext(HunkActions);
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
<div
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
>
<span className="min-w-0 flex-1 truncate">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</span>
</div>
);
}
function UnifiedLine({
line,
lang,
emph,
style,
bar,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
/** This is the hunk's last changed line: hang the review bar under it. */
bar?: LaneDiffHunk;
}) {
return (
<Row tint={rowTint(line.type)} style={style}>
<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} />
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const { emph } = analyseHunk(hunk);
const last = lastChangedLine(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<UnifiedLine
key={i}
line={line}
lang={lang}
emph={emph.get(i)}
bar={i === last ? hunk : undefined}
/>
))}
</div>
);
}
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
* unified, a paired row in split. */
type Item = { hunk: LaneDiffHunk; i: number };
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
const out: Item[] = [];
for (const hunk of hunks) {
out.push({ hunk, i: -1 });
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
}
return out;
}
function longestInHunks(hunks: LaneDiffHunk[]) {
return longest(
(function* () {
for (const h of hunks) for (const l of h.lines) yield l.text;
})()
);
}
function SplitLine({
row,
lang,
side,
style,
bar,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffHunk;
}) {
const line = side === "left" ? row.left : row.right;
return (
<Row tint={rowTint(line?.type ?? null)} style={style}>
<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" ? row.emph?.del : row.emph?.add}
/>
) : null}
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
/** 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} />
{analyseHunk(hunk).rows.map((row, i) => (
<SplitLine
key={i}
row={row}
lang={lang}
side={side}
// One bar per hunk: on the right (new) column only.
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
))}
</div>
))}
</>
);
}
/** A windowed split column: the shared range of items, one side of each. */
function SplitSlice({
items,
range,
lang,
side,
}: {
items: Item[];
range: { start: number; end: number };
lang: string;
side: "left" | "right";
}) {
return (
<>
{items
.slice(range.start, range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed />
) : (
<SplitLine
key={range.start + k}
row={analyseHunk(hunk).rows[i]!}
lang={lang}
side={side}
style={FIXED}
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
)
)}
</>
);
}
/** `v` held inside a scroller's own extent. */
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
return Math.min(v, Math.max(0, scrollSize - clientSize));
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
];
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
function follow(src: HTMLElement, dst: HTMLElement) {
for (const [pos, size, client] of AXES) {
// `src` pinned at its own end while `dst` sits past it: `src` is the
// narrower column echoing a clamp we just caused. Pulling `dst` back to
// it would snap the wide column home every time it scrolls past the
// narrow one's longest line.
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
const target = clampScroll(src[pos], dst[size], dst[client]);
if (dst[pos] !== target) dst[pos] = target;
}
}
/** Both sides, each its own scroller but kept in step on both axes. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
const win = useWindow(items.length);
// Mirroring fires the other pane's own scroll handler, which mirrors back —
// the equality check (and `follow`'s edge rule) is what ends that, so no
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
// vertical drift puts a deletion beside some unrelated line. Both columns
// window off the same offset, so they always hold the same row range.
const sync = (from: "left" | "right") => {
const src = from === "left" ? left.current : right.current;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst) return;
follow(src, dst);
win.onScroll(src);
};
const column = (side: "left" | "right") =>
win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
</Windowed>
) : (
<SplitColumn hunks={hunks} lang={lang} side={side} />
);
return (
<div className={`flex ${boxHeight(fill, win.on)}`}>
<div
ref={left}
data-testid="lane-diff-split-left"
onScroll={() => sync("left")}
className="w-1/2 min-w-0 overflow-auto"
>
{column("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"
>
{column("right")}
</div>
</div>
);
}
/** One file's diff, in the chosen layout. */
export function LaneDiffView({
file,
layout,
fill = false,
hunkActions,
}: {
file: LaneChangeFile;
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
/** Controls drawn in each hunk's review bar. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
}) {
const root = useRef<HTMLDivElement>(null);
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
const hunks = file.hunks;
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
const minWidth = useMemo(
// two gutters + the row's px-2, plus the marker column
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
[hunks]
);
// Unified only; split windows inside its own two scrollers.
const win = useWindow(layout === "unified" ? items.length : 0);
const splitWindowed = useMemo(
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
[layout, hunks]
);
// The bar's arrows: bring hunk `index`'s header into view. A windowed
// unified diff may not have it in the DOM, so there it is found by offset.
const go = (index: number) => {
const el = root.current;
if (!el || !hunks) return;
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
// The review list pins each file's header over its own diff.
const sticky = el
.closest("[data-review-file]")
?.querySelector<HTMLElement>("[data-sticky-top]");
const pad = sticky?.offsetHeight ?? 0;
if (header) {
scrollToTop(header, pad);
return;
}
// Windowed: the header is not in the DOM, but its offset is known.
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
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 (
<HunkActions.Provider value={review}>
<div
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split"
? boxHeight(fill, splitWindowed)
: `overflow-auto ${boxHeight(fill, win.on)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
{items
.slice(win.range.start, win.range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
) : (
<UnifiedLine
key={win.range.start + k}
line={hunk.lines[i]!}
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={i === lastChangedLine(hunk) ? hunk : undefined}
/>
)
)}
</Windowed>
) : (
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>
</HunkActions.Provider>
);
}
/** 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);
const win = useWindow(lines.length);
// gutter + the row's px-2
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
);
return (
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
>
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
{lines
.slice(win.range.start, win.range.end)
.map((text, k) => row(text, win.range.start + k, FIXED))}
</Windowed>
) : (
lines.map((text, i) => row(text, i))
)}
{truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
);
}
export default LaneDiffView;
+564
View File
@@ -0,0 +1,564 @@
/**
* @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, switch branch, create/delete a branch, publish a repo
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
* (the picked branch, or the upstream once diverged; resolving it is the
* Changes panel's job). Committing is
* NOT here: it is the Changes panel's staged-only commit box, beside the
* per-file stage/unstage/discard it commits — one way to commit, and it only
* ever takes what the user staged.
*
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are 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. The one exception
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
* in, so a remote wanting a login opens a form here and the SAME action is
* resent with the typed credential — the token is never rendered back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
CloudUpload,
GitBranch,
GitMerge,
Plus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import { api, type GitAuthNeeded } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
| "fetch"
| "pull"
| "push"
| "switch"
| "branch-create"
| "branch-delete"
| "merge"
| "publish";
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
function Btn({
label,
icon,
busy,
disabled,
title,
compact,
onClick,
}: {
label: string;
icon?: React.ReactNode;
busy?: boolean;
disabled?: boolean;
title?: string;
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
compact?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || busy}
title={title}
aria-label={compact ? label : undefined}
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
}`}
>
{icon}
{!compact && label}
</button>
);
}
export function LaneGitBar({
laneId,
git,
onChanged,
onNewLane,
lead,
}: {
laneId: number;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
lead?: React.ReactNode;
/** 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 [switching, setSwitching] = useState(false);
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
// Set when git needed a login for `action`; the form resends that action.
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [publishing, setPublishing] = useState(false);
const [url, setUrl] = useState("");
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
// render the moment the id changes, before anything can act on it.
const [shownLane, setShownLane] = useState(laneId);
// One action at a time, checked synchronously: `busy` is state, and a second
// Enter can land before the render that disables anything.
const inFlight = useRef(false);
if (shownLane !== laneId) {
setShownLane(laneId);
setBusy(null);
setError(null);
setSwitching(false);
setBranches(null);
setWorktrees([]);
setPicked("");
setNewBranch("");
setLogin(null);
setPassword("");
setPublishing(false);
setUrl("");
inFlight.current = false;
}
const laneNow = useRef(laneId);
laneNow.current = laneId;
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
const merging = !!git.merging;
const run = async (action: Action, body?: RunBody) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
setBusy(action);
setError(null);
try {
const res = await api.lanes.gitAction(lane, action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
// A merge that stopped on conflicts still succeeded in starting; the
// Changes panel below lists the files to resolve.
if (res?.conflicts?.length)
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
setLogin(null);
setPassword("");
// 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 === "publish") {
setPublishing(false);
setUrl("");
} else if (action === "switch" || action.startsWith("branch-")) {
if (action === "switch") setSwitching(false);
else 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.
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
setLogin({ action, auth });
setUsername((u) => u || auth.username || "");
setPassword("");
} else if (laneNow.current === lane)
setError(
(err as { code?: string })?.code === "ENOREMOTE"
? t("gitBar.errNoRemote")
: err instanceof Error
? err.message
: String(err)
);
} finally {
if (laneNow.current === lane) {
inFlight.current = false;
setBusy(null);
}
}
};
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
} catch {
if (laneNow.current === laneId) setBranches([]);
}
};
const openSwitch = async () => {
setSwitching((v) => !v);
setError(null);
if (branches) return;
await refreshBranches();
};
const merge = (branch: string) => {
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
void run("merge", { branch });
};
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="space-y-1">
<div className="flex min-w-0 items-center gap-2">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{/* Null counts with an upstream named means git could not compare
the two (the upstream ref is gone): claiming "in sync" there
would be a lie, so it reads as not pushed. */}
{git.unborn ? (
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
{t("gitBar.noCommits")}
</span>
) : git.upstream && git.ahead != null && git.behind != null ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
className="flex shrink-0 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="shrink-0 font-mono text-[11px] text-fg-muted">
{t("gitBar.noUpstream")}
</span>
)
)}
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
<Btn
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
icon={<GitMerge className="h-3 w-3" />}
busy={busy === "merge"}
disabled={!!busy || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
onClick={() => merge(git.upstream as string)}
/>
)}
{/* A repo with no commit has nothing to push, fetch into or branch
from — the one next step is the commit box below. */}
{git.unborn ? (
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
) : (
<div className="ml-auto flex shrink-0 items-center">
<Btn
compact
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3.5 w-3.5" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
compact
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy || merging}
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
compact
// Same `push -u <remote> HEAD` either way; a branch with no
// upstream is being published, not updated, so it says that.
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
icon={<Upload className="h-3.5 w-3.5" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={
git.hasRemote && !git.upstream
? t("gitBar.publishBranchHint")
: t("gitBar.pushHint")
}
onClick={() => run("push")}
/>
{!git.hasRemote && (
<Btn
compact
label={t("gitBar.publish")}
icon={<CloudUpload className="h-3.5 w-3.5" />}
disabled={!!busy}
title={t("gitBar.publishHint")}
onClick={() => {
setPublishing((v) => !v);
setError(null);
}}
/>
)}
<Btn
compact
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={
merging
? t("gitBar.merging")
: dirty > 0
? t("gitBar.switchDirty")
: t("gitBar.switchHint")
}
onClick={openSwitch}
/>
</div>
)}
</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 })}
/>
<Btn
label={t("gitBar.merge")}
icon={<GitMerge className="h-3 w-3" />}
busy={busy === "merge"}
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
onClick={() => merge(picked)}
/>
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
// A branch any worktree holds — this lane's own included — is
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
title={
picked && (heldBy.has(picked) || picked === git.branch)
? t("gitBar.newLaneHeld", {
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
// 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>
)}
{publishing && !git.hasRemote && !git.unborn && (
<div className="flex items-center gap-1.5">
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
}}
// The server decides which URLs are acceptable (https without a
// token, ssh, user@host:path); this only names the two usual ones.
placeholder={t("gitBar.publishUrl")}
aria-label={t("gitBar.publishUrl")}
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.publishApply")}
icon={<Upload className="h-3 w-3" />}
busy={busy === "publish"}
disabled={!url.trim()}
title={t("gitBar.publishHint")}
onClick={() => void run("publish", { url: url.trim() })}
/>
</div>
)}
{login && (
<form
data-testid="lane-git-login"
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
onSubmit={(e) => {
e.preventDefault();
const { protocol, host } = login.auth;
void run(login.action, {
credentials: { protocol, host, username: username.trim(), password, remember },
});
}}
>
<p className="font-medium text-fg-primary">
{t("gitBar.loginTitle", { host: login.auth.host })}
</p>
{login.auth.rejected && (
<p role="alert" className="text-status-danger">
{t("gitBar.loginRejected")}
</p>
)}
<div className="flex gap-1.5">
<input
aria-label={t("gitBar.loginUser")}
placeholder={t("gitBar.loginUser")}
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<input
type="password"
aria-label={t("gitBar.loginPassword")}
placeholder={t("gitBar.loginPassword")}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<label className="flex items-center gap-1.5 text-fg-secondary">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
{t("gitBar.loginRemember")}
</label>
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
{/* The token crosses the network in the clear on a plain-http LAN address. */}
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
)}
<div className="flex gap-1.5">
<button
type="submit"
disabled={!username.trim() || !password || !!busy}
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitBar.loginSubmit")}
</button>
<Btn
label={t("gitBar.loginCancel")}
onClick={() => {
setLogin(null);
setPassword("");
}}
/>
</div>
</form>
)}
{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;
@@ -0,0 +1,294 @@
/**
* @file The top of the Changes panel: the lane's working-copy line (branch,
* head, subject, and the uncommitted counts that open a per-file hover panel)
* and the git action bar under it (`LaneGitBar`).
*
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
* owned by the panel) and refresh each other after every write.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
/** How often the mounted panel re-reads its working-copy facts. Slow on
* purpose: each call is five git subprocesses server-side, and a branch name
* does not change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;
/**
* The lane's own working copy, fetched by the Changes panel rather than folded
* into the polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders with no git header.
*/
export 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);
// A lane switch drops the previous lane's facts during render, so nothing
// (the git bar's "current branch" included) shows them until the new read.
const [factsLane, setFactsLane] = useState(laneId);
if (factsLane !== laneId) {
setFactsLane(laneId);
setFacts(null);
}
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
.catch(() => {
// Silent: a panel that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip.
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
/** Porcelain status code -> the letter shown and the colour it is shown in.
* Keyed on the code's first meaningful character, so an index-side change
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
const GIT_STATUS_STYLE: Record<string, string> = {
M: "text-status-warning",
A: "text-status-success",
D: "text-status-danger",
R: "text-accent",
C: "text-accent",
U: "text-status-danger",
"?": "text-fg-muted",
};
/**
* The file list behind a lane's uncommitted counts: which files, what happened
* to each, and how many lines it gained and lost.
*
* It replaced a native `title` tooltip, which could show the same paths but
* neither colour them nor align the deltas — and a plain porcelain dump is not
* something you read at a glance.
*
* PORTALLED to `document.body` and `position: fixed`: the Changes panel
* scrolls in its own `overflow` box, and an absolutely positioned panel would
* be clipped by it. It flips above its anchor rather than off the bottom of
* the window, and clamps to the right edge.
*/
function LaneGitFilesPanel({
files,
hidden,
anchor,
onPointerEnter,
onPointerLeave,
}: {
files: LaneGitFile[];
hidden: number;
/** The counts button this panel hangs off. */
anchor: HTMLElement | null;
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// After the first paint, when the panel's real height is known: place it
// under the anchor, or above it when that would run off the bottom.
useLayoutEffect(() => {
if (!anchor || !panelRef.current) return;
const a = anchor.getBoundingClientRect();
const p = panelRef.current.getBoundingClientRect();
const gap = 4;
const top =
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
setPos({
top: Math.max(8, top),
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
});
}, [anchor, files]);
// Totals are over the LISTED files only — the cap means the rest are unknown,
// and a total that silently omits them would be a wrong number rather than a
// partial one. `hidden` says how many are missing.
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
return createPortal(
<div
ref={panelRef}
data-testid="lane-git-files"
role="group"
aria-label={t("git.filesHeading", { count: files.length })}
// The pointer has to be able to travel INTO the panel to scroll it, so
// the panel answers the same enter/leave pair the trigger does.
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
style={{
position: "fixed",
zIndex: 60,
top: pos?.top ?? 0,
left: pos?.left ?? 0,
// Hidden for the one frame before it has been measured, so it never
// flashes at the top-left corner.
visibility: pos ? "visible" : "hidden",
}}
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
>
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
<span className="ml-auto shrink-0 font-mono tabular-nums">
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
</span>
</div>
<ul className="space-y-0.5 font-mono text-[11px]">
{files.map((f) => {
const code = f.status.trim()[0] || "?";
return (
<li key={f.path} className="flex items-baseline gap-2">
<span
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
title={f.status}
>
{code}
</span>
{/* The DIRECTORY truncates, never the filename: the name is what
you scan for, the path is only there to disambiguate it. */}
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
</span>
<span className="shrink-0 tabular-nums">
{f.added === null && f.deleted === null ? (
<span className="text-fg-muted">{t("git.noDelta")}</span>
) : (
<>
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
<span className="text-status-danger">−{f.deleted ?? 0}</span>
</>
)}
</span>
</li>
);
})}
</ul>
{hidden > 0 && (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
)}
</div>,
document.body
);
}
export function LaneGitHeader({
laneId,
git,
onChanged,
onNewLane,
}: {
laneId: number;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
/** See `LaneGitBar`'s prop of the same name. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
// Closing is DELAYED: the panel is portalled away from the trigger, so the
// pointer leaves the trigger before it can land on the panel it is heading
// for. Any enter — trigger or panel — cancels the pending close.
const filesCloseTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
if (!git?.available) return null;
const lead = (
<>
{/* One row with the actions: branch first and strongest, then the head
and its subject, which is what truncates when the panel is narrow
(the whole of it stays in the tooltip). */}
<div
data-testid="lane-git"
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
title={`${git.head} ${git.subject}`}
>
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<GitBranch className="h-3 w-3 text-fg-muted" />
{git.branch}
</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<button
type="button"
ref={setCountsAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
onClick={() => setFilesOpen((v) => !v)}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</button>
)}
</>
);
return (
<div
data-testid="lane-git-header"
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
>
<LaneGitBar
laneId={laneId}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
lead={lead}
/>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div>
);
}
+63 -31
View File
@@ -1,13 +1,21 @@
/**
* @file The compact lane tile used in the Workspace's horizontal lane strip.
* It carries only what you need to pick a lane — which lane, is it alive,
* what stage, how far — because the full card, its controls and its
* working-copy facts live in the detail panel below it. Keeping the tile
* small and fixed-width is what lets many lanes stay scannable in one
* scrolling row.
* what stage — because the full card, its controls and its working-copy facts
* live in the detail panel below it. Stage badges ride on the id row rather
* than a line of their own, and progress (bar and percent) is deliberately
* absent: the detail panel's pipeline map below shows the same thing with the
* stage names attached. Keeping the tile small and fixed-width is what lets
* many lanes stay scannable in one scrolling row.
*
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
* the keyboard (and touch-free) path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
@@ -43,46 +51,70 @@ function detectionLeads(lane: Lane): boolean {
export default function LaneStripCard({
lane,
index,
selected,
onSelect,
onReorder,
}: {
lane: Lane;
/** Position in the lane array — the unit both reorder paths speak in. */
index: number;
selected: boolean;
onSelect: () => void;
onReorder: (from: number, to: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [dropTarget, setDropTarget] = useState(false);
return (
<button
type="button"
data-testid={`lane-tile-${lane.id}`}
data-selected={selected ? "true" : undefined}
data-drop-target={dropTarget ? "true" : undefined}
aria-pressed={selected}
onClick={onSelect}
title={lane.cwd}
className={`w-56 shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
selected
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
draggable
onDragStart={(e) => {
// Firefox starts no drag at all without a setData call.
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
e.preventDefault();
setDropTarget(false);
const from = Number(e.dataTransfer.getData("text/plain"));
if (Number.isInteger(from)) onReorder(from, index);
}}
onKeyDown={(e) => {
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
}}
title={`${lane.cwd}\n${t("reorderHint")}`}
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
dropTarget
? "border-accent bg-accent/20"
: selected
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`}
>
<div className="mb-1.5 flex items-center gap-1.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
<span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
{t("cardId", { id: lane.id })}
{/* The bare id, not "Lane 23": the word cost a third of the row's
width and said nothing, but the number itself is how the CLI
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
#{lane.id}
</span>
{lane.needs_action && (
<span className="ml-auto text-status-warning" title={lane.needs_action}>
⚠
</span>
)}
</div>
<div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
{lane.title || lane.cwd}
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
@@ -100,16 +132,16 @@ export default function LaneStripCard({
{lane.detected_stage}
</span>
)}
{lane.progress > 0 && (
<span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
{lane.needs_action && (
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
⚠
</span>
)}
</div>
{lane.progress > 0 && (
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
</div>
)}
<div className="truncate text-[13px] font-medium text-fg-primary">
{lane.title || lane.cwd}
</div>
</button>
);
}
@@ -29,7 +29,10 @@ vi.mock("../../../lib/api", () => ({
profileInit: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
run: { start: vi.fn() },
},
}));
@@ -106,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();
@@ -131,10 +122,149 @@ beforeEach(() => {
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
tool: "claude",
file: null,
restart_required: false,
run_id: null,
profile: "native",
});
// 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)
.mockReset()
.mockResolvedValue({ items: [nativeRouter()] });
});
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
function nativeRouter() {
return {
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",
},
};
}
/** A saved router profile, for the tests that need the radio to have a target. */
function savedRouter(over: Record<string, unknown> = {}) {
return {
...nativeRouter(),
name: "9router",
label: "9Router",
builtin: false,
hasKey: true,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
...over,
};
}
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");
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
// the form cannot see, so the placeholder only says the field is optional.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"Automatic — named from the title"
);
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();
});
@@ -143,6 +273,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main", "feat/other"],
current: "main",
worktrees: [],
});
renderModal();
const user = userEvent.setup();
@@ -174,8 +305,34 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: [],
current: "main",
worktrees: [],
});
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/empty");
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
await user.type(screen.getByLabelText("Title"), "demo");
// There is nothing to branch from, so the worktree cannot be created.
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
});
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();
@@ -191,20 +348,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 });
@@ -223,7 +384,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,
});
@@ -242,7 +407,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,
});
@@ -261,11 +430,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();
@@ -273,17 +447,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,
});
@@ -346,8 +525,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();
@@ -356,6 +535,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", () => {
@@ -398,47 +599,145 @@ describe("AddLaneModal — folder browse", () => {
});
});
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.
describe("AddLaneModal — create/update readme", () => {
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" }));
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
});
});
describe("AddLaneModal — AI Router", () => {
it("applies native without asking, so a lane always has a declared endpoint", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle" }),
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
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" })
)
);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
});
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"));
it("sends the picked profile once AI Router is chosen", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
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(await screen.findByRole("radio", { name: "AI Router" }));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
});
it("keeps the radio on Native when no profile is saved, and says why", async () => {
// Choosing "AI Router" with nothing to point at would create a lane whose
// declared endpoint does not exist.
renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
it("creates the lane anyway when the profile list cannot be fetched", async () => {
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
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.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
});
});
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
it("reports the adopted lane even when applying the router fails", async () => {
// `ensure` already created it server-side; skipping onAdded would hide a
// real lane from the list until some unrelated refetch found it.
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const onAdded = vi.fn();
renderModal({ onAdded });
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.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open.
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
});
});
describe("AddLaneModal — a router profile that disappeared", () => {
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
// Deleted in another tab while this modal sat open: the select would show
// no matching option while state still held the dead name.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const { rerender } = renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
// Re-open with the profile gone from the server's answer.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
// Clicking it early resolved back to Native with no feedback, which reads
// as a dead control.
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
vi.mocked(api.aiRouters.list).mockReturnValue(
new Promise((resolve) => {
resolveList = resolve;
}) as never
);
renderModal();
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() => expect(select).toHaveValue("default"));
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
resolveList({ items: [nativeRouter(), savedRouter()] });
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
});
});
@@ -486,3 +785,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,9 +9,9 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu } from "../LaneCard";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api";
@@ -23,14 +23,21 @@ vi.mock("../../../lib/api", () => ({
up: vi.fn(),
down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
integration: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
routerApply: vi.fn(),
},
locks: {
list: vi.fn(),
},
aiRouters: {
list: vi.fn(),
},
run: {
kill: vi.fn(),
start: vi.fn(),
},
},
}));
@@ -41,8 +48,10 @@ beforeEach(() => {
// stack, so the runtime strip and its button stay absent unless a test opts in.
vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
vi.mocked(api.lanes.integration).mockReset();
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
// Only the built-in profile by default: a user who never opened the AI Router
// tab has nothing to switch between, so the row stays out of every other test.
vi.mocked(api.aiRouters.list).mockReset();
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
});
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
@@ -150,19 +159,17 @@ describe("LaneCard rebuilt layout", () => {
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
});
it("shows the progress percentage", () => {
// The pipeline map below this card draws the same progress as named nodes,
// so the card no longer repeats it as a bar and a percent.
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByText("48%")).toBeInTheDocument();
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
expect(screen.queryByText("48%")).toBeNull();
});
it("gives the progress bar a width matching the lane's progress", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
});
it("surfaces a needs-you message", () => {
it("leaves the needs-you message to the strip card", () => {
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
expect(screen.queryByText(/waiting on approval/)).toBeNull();
});
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
@@ -197,64 +204,11 @@ describe("LaneCard rebuilt layout", () => {
});
describe("LaneCard git block", () => {
const facts = {
available: true as const,
branch: "feat/rename-metric",
head: "9b3e74a",
subject: "free-text rule mode in the form",
dirty: 2,
untracked: 1,
};
it("renders the live branch, head, subject and uncommitted counts", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(block.textContent).toContain("2");
// The live branch replaces the lane's recorded one rather than doubling it.
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
});
it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).not.toContain("modified");
});
it("renders the card with no git block and no error when git is unavailable", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
it("carries no working-copy row — that heads the Changes panel now", async () => {
render(<LaneCard lane={makeLane({ id: 4 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-4")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
});
it("swallows a rejected request instead of surfacing an error", async () => {
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
expect(screen.queryByText(/network down/)).toBeNull();
});
it("stops polling once the card unmounts", async () => {
vi.useFakeTimers();
try {
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
unmount();
vi.advanceTimersByTime(120_000);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
expect(api.lanes.git).not.toHaveBeenCalled();
});
});
@@ -340,40 +294,6 @@ describe("LaneCard — named locks", () => {
});
});
describe("LaneCard — integration badges", () => {
beforeEach(() => {
// Only the fields this describe block's assertions read; `as LaneRuntime`
// keeps the double from having to restate a shape the component never
// touches here.
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const,
provisioned: true as const,
up: false,
slot: 1,
profileDir: "/work/demo/.agenthub/profile",
ports: {},
} as unknown as LaneRuntime);
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
Promise.resolve({ enabled: name === "tracker" })
);
});
it("shows integration badges reflecting each toggle's state", async () => {
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const tracker = await screen.findByTestId("lane-integration-tracker");
const devQc = await screen.findByTestId("lane-integration-dev_qc");
expect(tracker.className).toContain("status-success");
expect(devQc.className).not.toContain("status-success");
});
it("hides integration badges when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
});
});
describe("LaneToolsMenu", () => {
beforeEach(() => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
@@ -433,6 +353,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()} />);
@@ -440,3 +383,168 @@ describe("LaneToolsMenu", () => {
expect(container).toBeEmptyDOMElement();
});
});
describe("LaneRouterRow — the Model picker", () => {
const native = {
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",
},
};
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
it("stays out of the card when native is the only profile", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
render(<LaneRouterRow lane={makeLane()} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("lane-router-1")).toBeNull();
});
it("shows the lane's current profile selected", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("applies a switch through the router route, not a lane PATCH", 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).toHaveBeenCalledWith(1, "9router"));
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"));
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
/**
* @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. Also pins the clamped mirroring (a narrow column may
* not drag the wide one back) and the row windowing of large diffs.
* @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,
LaneFileView,
ROW_PX,
WINDOW_THRESHOLD,
analyseHunk,
visibleRange,
} 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)}";` },
],
},
],
};
/** jsdom has no layout: give a scroller the extents a browser would. */
function layout(
el: HTMLElement,
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
) {
for (const [k, v] of Object.entries(g))
Object.defineProperty(el, k, { value: v, configurable: true });
}
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
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("caps a review diff only when it windows; the single-file view fills its parent", () => {
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
// windowed one keeps the cap because windowing needs its own scroller.
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
const big: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
type: "ctx" as const,
oldLine: i + 1,
newLine: i + 1,
text: `line ${i}`,
})),
},
],
};
rerender(<LaneDiffView file={big} 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");
layout(left, ROOMY);
layout(right, ROOMY);
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 split columns on the same row vertically too", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollTop = 480;
fireEvent.scroll(left);
expect(right.scrollTop).toBe(480);
right.scrollTop = 40;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(40);
});
it("clamps the mirrored offset to the other column's own extent", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
right.scrollLeft = 600;
right.scrollTop = 250;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(300);
expect(left.scrollTop).toBe(100);
});
it("does not let the narrow column's clamped echo drag the wide one back", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200 });
right.scrollLeft = 600;
fireEvent.scroll(right);
// A browser fires the narrow column's scroll for the clamped write.
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(600);
// A real move of the narrow column still carries the wide one.
left.scrollLeft = 100;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(100);
});
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)/);
});
});
describe("analyseHunk", () => {
const hunk = {
header: "@@ -1,4 +1,4 @@",
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: "foo = 1" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
],
};
it("pairs each del/add with its emphasis, keyed by line index", () => {
const a = analyseHunk(hunk);
expect(a.rows).toHaveLength(3);
expect(a.emph.get(1)).toEqual([6, 7]);
expect(a.emph.get(2)).toEqual([6, 7]);
expect(a.emph.get(3)).toEqual([6, 7]);
expect(a.emph.get(4)).toEqual([6, 7]);
expect(a.emph.has(0)).toBe(false);
});
it("computes once per hunk, so both split columns and re-renders share it", () => {
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
});
});
describe("visibleRange", () => {
it("covers the viewport plus overscan on both sides", () => {
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
});
it("clamps a scrollTop past the content to the last full viewport", () => {
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
});
it("never runs past a short list", () => {
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
});
});
function bigFile(n: number): LaneChangeFile {
const lines = Array.from({ length: n }, (_, i) =>
i % 2
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
);
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
return fileWith(lines);
}
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
describe("LaneDiffView — windowing", () => {
it("renders every row below the threshold, with no spacers", () => {
const { container } = render(
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
);
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
});
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const rows = rowsIn(container);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(300);
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
// header + 5000 lines, every item one fixed row tall
const rendered = container.querySelectorAll("[data-diff-item]").length;
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
});
it("moves the window with the scroll", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
expect(container.textContent).not.toContain("line 3000;");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("line 3000;");
expect(container.textContent).not.toContain("line 10;");
});
it("starts windowing where the reader is when a diff grows past the threshold", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 350 * ROW_PX;
fireEvent.scroll(view);
// Expanding context replaces the file in place, same mounted view.
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
expect(container.textContent).toContain("line 350;");
});
it("starts from the top again after a round trip through split", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
// In split the outer box is no scroller; the browser drops its offset.
screen.getByTestId("lane-diff-view").scrollTop = 0;
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
expect(container.textContent).toContain("line 5;");
});
it("holds the horizontal extent at the longest line, rendered or not", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
// the 300-w line 7 is off screen now, and still sets the width
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
});
it("windows both split columns over the same row range", () => {
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
right.scrollTop = 2000 * ROW_PX;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(2000 * ROW_PX);
const l = rowsIn(left);
const r = rowsIn(right);
expect(l.length).toBeLessThan(300);
expect(l).toHaveLength(r.length);
expect(right.textContent).toContain("line 2001;");
// same rows: the right column's add has its filler row on the left
expect(left.textContent).toContain("line 2000;");
});
it("windows a whole-file read too", () => {
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
expect(rowsIn(container).length).toBeLessThan(300);
const view = screen.getByTestId("lane-file-view");
view.scrollTop = 4000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("row 4000");
});
});
describe("LaneDiffView — hunk review bar navigation", () => {
const hunk = (n: number) => ({
header: `@@ -${n},1 +${n},1 @@`,
context: "",
oldStart: n,
newStart: n,
lines: [
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
],
});
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
function renderInScroller() {
const view = render(
<div data-testid="scroller" style={{ overflowY: "auto" }}>
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
</div>
);
const scroller = screen.getByTestId("scroller");
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
Object.defineProperty(scroller, "clientHeight", { value: 100 });
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
const k = Number(el.dataset.hunkIndex);
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
}
return scroller;
}
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
const scroller = renderInScroller();
const next = screen.getAllByRole("button", { name: "Next change" });
const prev = screen.getAllByRole("button", { name: "Previous change" });
fireEvent.click(next[0]!); // bar 1 → hunk 2
expect(scroller.scrollTop).toBe(100);
fireEvent.click(next[1]!); // bar 2 → hunk 3
expect(scroller.scrollTop).toBe(200);
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
expect(scroller.scrollTop).toBe(100);
});
it("counts the hunks and disables the arrows at either end", () => {
renderInScroller();
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
});
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
render(<LaneDiffView file={three} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
});
@@ -0,0 +1,406 @@
/**
* @file LaneGitBar.test.tsx
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press (and that it may publish to a typed URL),
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
* Changes panel's staged-only box owns that), 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",
unborn: false,
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 branches.
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
});
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
});
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
render(
<LaneGitBar
laneId={3}
git={facts({
branch: "main",
head: null,
unborn: true,
subject: "",
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
})}
onChanged={vi.fn()}
/>
);
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
});
it("publishes a repo with no remote to a typed URL", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(
<LaneGitBar
laneId={3}
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
onChanged={onChanged}
/>
);
await user.click(screen.getByRole("button", { name: "Publish…" }));
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
await user.click(screen.getByRole("button", { name: "Publish" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
url: "git@host:owner/repo.git",
})
);
expect(onChanged).toHaveBeenCalled();
});
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
const user = userEvent.setup();
render(
<LaneGitBar
laneId={3}
git={facts({ upstream: null, ahead: null, behind: null })}
onChanged={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
await user.click(screen.getByRole("button", { name: "Publish branch" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
});
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("re-reads the branch list after a switch, so the branch just left is not held", 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/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
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("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing", "feat/free"],
current: "feat/thing",
worktrees: [
{ path: "/repo/lane3", branch: "feat/thing" },
{ path: "/repo/dev", branch: "development" },
],
});
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const newLane = () => screen.getByRole("button", { name: "New lane here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await user.selectOptions(select, "feat/free");
expect(newLane()).toBeEnabled();
});
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");
});
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
});
it("sends one branch-create for a double Enter", async () => {
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
});
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
new Promise((r) => {
resolveOld = r;
})
);
const user = userEvent.setup();
const { rerender } = render(
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
const newLane = screen.getByRole("button", { name: "New lane here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
});
it("claims no sync state when the upstream comparison could not be counted", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
onChanged={vi.fn()}
/>
);
expect(screen.queryByText("in sync")).toBeNull();
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
});
it("explains a push with nowhere to go instead of the server's generic text", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
);
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(
"No remote to push to or pull from"
);
});
});
describe("git login (issue #45)", () => {
const authErr = (rejected = false) =>
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
code: "EGITAUTH",
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
});
it("asks for a login when git needs one, then resends the same action with it", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
const form = await screen.findByTestId("lane-git-login");
expect(form).toHaveTextContent("git.smartgift.io.vn");
expect(screen.getByLabelText("Username")).toHaveValue("vi");
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
credentials: {
protocol: "https",
host: "git.smartgift.io.vn",
username: "vi",
password: "s3cret",
remember: true,
},
})
);
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
expect(onChanged).toHaveBeenCalled();
expect(document.body.textContent).not.toContain("s3cret");
});
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
vi.mocked(api.lanes.gitAction)
.mockRejectedValueOnce(authErr())
.mockRejectedValueOnce(authErr(true));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await user.type(await screen.findByLabelText("Password or access token"), "bad");
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
});
});
describe("merge (issue #45)", () => {
it("merges the picked branch into the current one after a confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
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/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Merge into current" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
);
confirm.mockRestore();
});
it("offers to merge the upstream when the branch has diverged", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
);
});
it("disables pull and switch while a merge is in progress", () => {
render(
<LaneGitBar
laneId={3}
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
onChanged={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
});
});
@@ -0,0 +1,160 @@
/**
* @file LaneGitHeader.test.tsx
* @description Pins the Changes panel's working-copy line: the live branch,
* head, subject and uncommitted counts, the per-file hover panel behind the
* counts, that a lane switch drops the previous lane's facts at once, and that
* an unreadable repo renders nothing rather than an error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
});
/** The panel's own wiring: one facts read feeding the header. */
function Harness({ laneId }: { laneId: number }) {
const [git, refresh] = useLaneGitFacts(laneId);
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
}
describe("LaneGitHeader", () => {
const facts = {
available: true as const,
branch: "feat/rename-metric",
head: "9b3e74a",
subject: "free-text rule mode in the form",
dirty: 2,
untracked: 1,
files: [
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
{ 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 () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
});
it("drops the previous lane's facts the moment the lane changes", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
const { rerender } = render(<Harness laneId={1} />);
await screen.findByText(/feat\/rename-metric/);
// The next lane's read never lands: nothing of lane 1's git may linger.
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
rerender(<Harness laneId={2} />);
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
});
it("names the files behind the counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
// Hidden until asked for: the row is a one-line summary until you hover.
expect(screen.queryByTestId("lane-git-files")).toBeNull();
fireEvent.mouseEnter(counts);
const panel = screen.getByTestId("lane-git-files");
expect(panel.textContent).toContain("alerts.js");
expect(panel.textContent).toContain("+12");
expect(panel.textContent).toContain("−3");
// Totals across the listed files.
expect(panel.textContent).toContain("+13");
// An untracked file has no delta to report and says so rather than "+0".
expect(panel.textContent).toContain("new");
expect(panel.textContent).toContain("3 files changed");
// Closing is delayed so the pointer can travel into the panel and scroll
// it — the panel is portalled to the body, away from its trigger.
fireEvent.mouseLeave(counts);
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
});
it("stays open while the pointer is inside the panel", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
fireEvent.mouseLeave(counts);
// The pointer landed on the panel before the pending close fired.
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
await new Promise((r) => setTimeout(r, 200));
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
});
it("says how many files the server-side cap left out", async () => {
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
render(<Harness laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
});
it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<Harness laneId={1} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).not.toContain("modified");
});
it("renders nothing and no error when git is unavailable", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
render(<Harness laneId={2} />);
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
expect(screen.queryByTestId("lane-git-header")).toBeNull();
});
it("swallows a rejected request instead of surfacing an error", async () => {
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
render(<Harness laneId={3} />);
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
expect(screen.queryByTestId("lane-git")).toBeNull();
expect(screen.queryByText(/network down/)).toBeNull();
});
it("stops polling once the panel unmounts", async () => {
vi.useFakeTimers();
try {
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
const { unmount } = render(<Harness laneId={1} />);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
unmount();
vi.advanceTimersByTime(120_000);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -1,13 +1,13 @@
/**
* @file Rendering tests for the Workspace lane tile, focused on the amber
* "detected" badge: it may only appear when the inferred stage is genuinely
* AHEAD of the declared one, including when the declaration used a node alias
* (which is most of what the ship-feature skill declares).
* @file Rendering tests for the Workspace lane tile: the amber "detected"
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
* declared one, including when the declaration used a node alias — which is
* most of what the ship-feature skill declares), and the reorder handles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import LaneStripCard from "../LaneStripCard";
import type { Lane, LaneNode } from "../../../lib/types";
@@ -63,8 +63,10 @@ describe("LaneStripCard detected badge", () => {
render(
<LaneStripCard
lane={lane({ detected_stage: "review" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
@@ -77,8 +79,10 @@ describe("LaneStripCard detected badge", () => {
render(
<LaneStripCard
lane={lane({ stage: "tests", detected_stage: "implementing" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
@@ -88,10 +92,104 @@ describe("LaneStripCard detected badge", () => {
render(
<LaneStripCard
lane={lane({ stage: "tests", detected_stage: "review" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
});
});
describe("LaneStripCard reorder", () => {
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
function dataTransfer(initial = "") {
let payload = initial;
return {
setData: (_type: string, v: string) => {
payload = v;
},
getData: () => payload,
effectAllowed: "",
dropEffect: "",
};
}
it("reports the dragged tile's index and the drop target's", () => {
const onReorder = vi.fn();
const dt = dataTransfer();
render(
<>
<LaneStripCard
lane={lane({ id: 1 })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
<LaneStripCard
lane={lane({ id: 2 })}
index={2}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
</>
);
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
expect(onReorder).toHaveBeenCalledWith(0, 2);
});
it("ignores a drop carrying no tile index", () => {
const onReorder = vi.fn();
render(
<LaneStripCard
lane={lane({})}
index={0}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
);
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
expect(onReorder).not.toHaveBeenCalled();
});
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
const onReorder = vi.fn();
render(
<LaneStripCard
lane={lane({})}
index={2}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
);
const tile = screen.getByTestId("lane-tile-1");
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
expect(onReorder).toHaveBeenCalledWith(2, 1);
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
expect(onReorder).toHaveBeenCalledWith(2, 3);
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
fireEvent.keyDown(tile, { key: "ArrowLeft" });
expect(onReorder).toHaveBeenCalledTimes(2);
});
it("shows the id without the 'Lane' prefix", () => {
render(
<LaneStripCard
lane={lane({ id: 7, title: "checkout flow" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
const tile = screen.getByTestId("lane-tile-7");
expect(tile).toHaveTextContent("#7");
expect(tile.textContent).not.toMatch(/Lane/i);
});
});
+177 -66
View File
@@ -15,10 +15,20 @@
* 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.
*
* Each pane is one tmux session on its lane — split panes all show the same
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane 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>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api";
@@ -38,6 +48,20 @@ import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
/** The claude lane a session's cwd belongs to: the longest path-boundary
* prefix, the same rule the server binds hook sessions with. External
* sessions are all claude — Codex emits no hooks. */
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
let best: Lane | null = null;
for (const l of lanes) {
if (l.tool !== "claude") continue;
const root = l.cwd.replace(/\/+$/, "");
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
}
return best?.id ?? null;
}
export interface LaneConsolePaneProps {
lanes: Lane[];
laneId: number | null;
@@ -53,6 +77,19 @@ export interface LaneConsolePaneProps {
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
/** Which split pane this is. Every pane shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
slot?: number;
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
* one terminal is never attached twice side by side. */
hiddenRunIds?: string[];
/** Reports the run this pane shows (null on the setup form). */
onHandleChange?: (runId: string | null) => void;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
export function LaneConsolePane({
@@ -68,6 +105,9 @@ export function LaneConsolePane({
wsConnected,
defaultCwd,
onHasActiveRunChange,
slot = 0,
hiddenRunIds,
onHandleChange,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -75,7 +115,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);
@@ -88,12 +128,37 @@ export function LaneConsolePane({
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude";
// The page polls runs and sessions for EVERY lane; this pane's Active runs
// list is this lane's only. No lane selected keeps the full lists.
const laneRuns = useMemo(
() =>
laneId === null || !activeRuns
? activeRuns
: {
...activeRuns,
items: activeRuns.items.filter(
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
),
},
[activeRuns, laneId, hiddenRunIds]
);
const laneSessions = useMemo(
() =>
laneId === null
? externalSessions
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
useEffect(() => {
onHandleChange?.(handle?.id ?? null);
}, [handle, onHandleChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
@@ -129,43 +194,19 @@ export function LaneConsolePane({
useEffect(() => {
const { activeRuns: runs } = latest.current;
setPrompt("");
lastPrefill.current = "";
setResumeSession(null);
setError(null);
setBusy(null);
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
const own = laneId === null ? null : paneRunId(laneId, slot);
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
autoAttachedForLane.current = null;
refreshList();
}, [laneId, refreshList]);
}, [laneId, slot, 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
@@ -177,12 +218,13 @@ export function LaneConsolePane({
useEffect(() => {
if (handle || laneId === null) return;
if (autoAttachedForLane.current === laneId) return;
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
const own = paneRunId(laneId, slot);
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
if (running) {
autoAttachedForLane.current = laneId;
setHandle(running);
}
}, [activeRuns, laneId, handle]);
}, [activeRuns, laneId, slot, handle]);
// The cwd tracks the lane's own folder separately, keyed on the resolved
// path rather than on `laneId` alone: the pane can mount before the lane
@@ -261,6 +303,7 @@ export function LaneConsolePane({
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
@@ -271,29 +314,31 @@ 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")) {
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);
return;
}
// 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.
// This pane's own session is the live one — land on it.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const fetched = await api.run.get(laneStartResult.lane.run_id);
const fetched = await api.run.get(startedId);
setHandle(fetched);
refreshList();
} catch {
try {
await attachToRun(laneStartResult.lane.run_id);
await attachToRun(startedId);
refreshList();
} catch (fallbackErr: unknown) {
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
@@ -307,7 +352,18 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
[
busy,
t,
lanes,
laneId,
laneTool,
slot,
onLaneIdChange,
onLaneCreated,
attachToRun,
refreshList,
]
);
const onResumeFromHistory = useCallback(
@@ -332,19 +388,38 @@ 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", {
slot,
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") {
onLaneIdChange(targetLaneId);
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error("No run_id returned from lane start");
}
fetched = await api.run.get(laneStartResult.lane.run_id);
fetched = await api.run.get(startedId);
onLaneIdChange(targetLaneId);
} else {
fetched = await api.run.start({
@@ -368,7 +443,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
@@ -385,6 +460,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">
@@ -426,13 +523,17 @@ export function LaneConsolePane({
</select>
)}
<header className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-4.5 h-4.5 text-accent" />
{/* Title, live badge and blurb on ONE line: the blurb is a standing
description of the panel, not a message, so it does not earn the
second row it used to wrap onto. It drops out entirely below `sm`,
where the width would wrap it again. */}
<header className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-3.5 h-3.5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<h1 className="text-base font-semibold text-fg-primary">
{t("title", { tool: toolLabel })}
</h1>
{wsConnected ? (
@@ -447,16 +548,19 @@ export function LaneConsolePane({
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle", { tool: toolLabel })}</p>
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
{t("subtitle", { tool: toolLabel })}
</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}
activeRuns={laneRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
runHistory={runHistory}
externalSessions={externalSessions}
externalSessions={laneSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onKill={killRun}
onRefresh={refreshList}
/>
</header>
@@ -510,6 +614,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}
+65 -15
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
* "Active runs" counts everything actually running in scope. 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";
@@ -52,7 +58,7 @@ function StatusPill({
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
@@ -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,23 +527,32 @@ 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);
const startedLabel = isNaN(startedDate.getTime())
? "-"
: startedDate.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
// 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.
const startedLabel =
!row.startedAt || isNaN(startedDate.getTime())
? "-"
: startedDate.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
// Without mode distinction, offer resume for any finished run with a session.
// An external session is live but has no attachable tmux session, so Resume
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
@@ -550,7 +574,7 @@ function UnifiedRunRowView({
)}
{row.external && (
<span
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
title={t("runs.externalHint")}
>
{t("runs.externalBadge")}
@@ -590,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 && (
+372 -98
View File
@@ -36,6 +36,7 @@ import {
ShieldAlert,
X,
FolderOpen,
GitBranch,
Home,
History as HistoryIcon,
Search,
@@ -52,45 +53,305 @@ import type {
RunStartArgs,
CodexSandbox,
CodexApproval,
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { Select } from "../Select";
// Minimal SlashCommand type (from deleted RunConsole)
export interface SlashCommand {
name: string;
source: "project" | "user" | "plugin" | "builtin";
source: "project" | "user" | "builtin";
description?: string;
}
// Minimal PromptEditor component (from deleted RunConsole)
// Commands the CLI answers itself. They are typed into the pane like any
// other text, so they do run — they are just not files anyone can list.
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
"help",
"clear",
"compact",
"config",
"model",
"memory",
"hooks",
"cost",
"agents",
"review",
"permissions",
"status",
"init",
"mcp",
"plugin",
"output-style",
"exit",
].map((name) => ({ name, source: "builtin" }));
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
project: "text-status-success border-status-success/40",
user: "text-accent border-accent/40",
builtin: "text-fg-muted border-border",
};
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
* description (3+ chars only, so one keystroke drags nothing tangential in). */
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
if (!q) return 1;
const n = name.toLowerCase();
if (n === q) return 1000;
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
const idx = n.indexOf(q);
if (idx >= 0) return 400 - Math.min(idx, 100);
let i = 0;
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
if (i === q.length) return 200;
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
return 0;
}
interface Trigger {
kind: "slash" | "file";
query: string;
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
start: number;
cursor: number;
}
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
function detectTrigger(value: string, cursor: number): Trigger | null {
let start = cursor;
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
const tok = value.slice(start, cursor);
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
return null;
}
interface PromptEditorProps {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
rows?: number;
slashCommands: SlashCommand[];
/** Offer slash commands. Off for codex: these are Claude Code's. */
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string;
}
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
function PromptEditor({
value,
onChange,
onSubmit,
placeholder,
rows = 5,
slash,
fileCwd,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
if (commandsCwd !== fileCwd) {
setCommandsCwd(fileCwd);
setCommands(null);
}
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
// Commands and skills are read the first time `/` is typed for this cwd —
// two directory walks nobody should pay for on every render of the form.
useEffect(() => {
if (kind !== "slash" || commands) return;
let live = true;
const toCmd = (c: CcMdItem): SlashCommand => ({
name: c.name,
source: c.scope,
description: c.frontmatter?.description || c.preview.slice(0, 80),
});
Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
]).then(([c, sk]) => {
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
});
return () => {
live = false;
};
}, [kind, commands, fileCwd]);
// Files: debounced, and only the newest query may land.
const query = trigger?.query ?? "";
useEffect(() => {
if (kind !== "file") return;
let live = true;
const tid = window.setTimeout(() => {
api.run
.files(fileCwd, query)
.then((r) => live && setFiles(r.items))
.catch(() => live && setFiles([]));
}, 120);
return () => {
live = false;
window.clearTimeout(tid);
};
}, [kind, query, fileCwd]);
const slashItems = useMemo(() => {
if (kind !== "slash" || !commands) return [];
const q = query.toLowerCase();
return commands
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
.filter((x) => x.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
a.cmd.name.localeCompare(b.cmd.name)
)
.map((x) => x.cmd);
}, [kind, commands, query]);
const count = kind === "file" ? files.length : slashItems.length;
const current = Math.min(active, Math.max(0, count - 1));
const pick = (token: string) => {
if (!trigger) return;
const before = value.slice(0, trigger.start);
const after = value.slice(trigger.cursor);
const sep = after.startsWith(" ") ? "" : " ";
onChange(before + token + sep + after);
setTrigger(null);
setActive(0);
const pos = before.length + token.length + 1;
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.setSelectionRange(pos, pos);
});
};
const pickAt = (i: number) => {
if (kind === "file") {
const f = files[i];
if (f) pick(`@${f}`);
} else {
const c = slashItems[i];
if (c) pick(`/${c.name}`);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
if (kind && count > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const step = e.key === "ArrowDown" ? 1 : -1;
setActive(Math.min(count - 1, Math.max(0, current + step)));
return;
}
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
pickAt(current);
return;
}
}
if (kind && e.key === "Escape") {
e.preventDefault();
setTrigger(null);
return;
}
// 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();
}
};
const retrack = (ta: HTMLTextAreaElement) => {
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
};
const rowClass = (i: number) =>
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
<div className="relative">
<textarea
ref={taRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
{kind && (
<div
role="listbox"
data-testid="prompt-autocomplete"
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
>
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
</div>
{count === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
</div>
) : kind === "slash" ? (
slashItems.map((c, i) => (
<button
key={`${c.source}:${c.name}`}
type="button"
role="option"
aria-selected={i === current}
// Keep focus in the textarea, or its blur closes the list first.
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={rowClass(i)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
>
{c.source}
</span>
</div>
{c.description && (
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
)}
</button>
))
) : (
files.map((f, i) => (
<button
key={f}
type="button"
role="option"
aria-selected={i === current}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
>
{f}
</button>
))
)}
</div>
)}
</div>
);
}
@@ -119,7 +380,6 @@ interface RunSetupProps {
* sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */
laneCwd?: string;
slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
/** The selected lane's CLI. Decides which advanced fields render. */
@@ -147,9 +407,84 @@ export function RunSetup(props: RunSetupProps) {
return (
<div className="rounded-xl border border-border bg-surface-1">
{/* Fresh vs resume source picker */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
{/* Run settings and the fresh-vs-resume source picker share one row:
settings left, source right. They used to be two rows with a divider
between them, which spent a band of height on four labelled selects
that are each one line tall. The resume picker below is the only part
that still gets a row of its own, and only while resume is chosen. */}
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("fields.cwd")}>
{isResume && props.resumeSession ? (
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="truncate">{props.resumeSession.cwd}</span>
</div>
) : (
<div title={t("fields.cwdHint")}>
<CwdAutocomplete
value={props.cwd}
onChange={props.onCwdChange}
suggestions={props.cwdSuggestions}
/>
</div>
)}
</Field>
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
{props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
value={props.sandbox}
onChange={props.onSandboxChange}
options={[
{ value: "read-only", label: t("fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
]}
/>
</Field>
<Field label={t("fields.approval")}>
<Select<CodexApproval>
value={props.approval}
onChange={props.onApprovalChange}
options={[
{ value: "on-request", label: t("fields.approvalOnRequest") },
{ value: "never", label: t("fields.approvalNever") },
]}
/>
</Field>
</>
) : (
<Field label={t("fields.permissionMode")}>
<Select<PermissionMode>
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") },
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
]}
/>
</Field>
)}
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
</div>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
<Seg
active={!resumePicked}
label={t("resume.freshOption")}
@@ -166,8 +501,11 @@ export function RunSetup(props: RunSetupProps) {
onClick={() => setResumePicked(true)}
/>
</div>
{resumePicked && (
<div className="min-w-0 flex-1">
</div>
{resumePicked && (
<div className="border-b border-border px-4 py-2 text-[11.5px]">
<div className="min-w-0">
{props.tool === "claude" ? (
<SessionPicker
selected={props.resumeSession}
@@ -192,8 +530,8 @@ export function RunSetup(props: RunSetupProps) {
</button>
)}
</div>
)}
</div>
</div>
)}
{/* Prompt */}
<div className="px-4 py-3 border-b border-border">
@@ -206,7 +544,7 @@ export function RunSetup(props: RunSetupProps) {
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
rows={5}
slashCommands={props.slashCommands ?? []}
slash={props.tool !== "codex"}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
@@ -214,78 +552,6 @@ export function RunSetup(props: RunSetupProps) {
</div>
</div>
{/* Advanced fields */}
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("fields.cwd")}>
{isResume && props.resumeSession ? (
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="truncate">{props.resumeSession.cwd}</span>
</div>
) : (
<div title={t("fields.cwdHint")}>
<CwdAutocomplete
value={props.cwd}
onChange={props.onCwdChange}
suggestions={props.cwdSuggestions}
/>
</div>
)}
</Field>
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
{props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
value={props.sandbox}
onChange={props.onSandboxChange}
options={[
{ value: "read-only", label: t("fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
]}
/>
</Field>
<Field label={t("fields.approval")}>
<Select<CodexApproval>
value={props.approval}
onChange={props.onApprovalChange}
options={[
{ value: "on-request", label: t("fields.approvalOnRequest") },
{ value: "never", label: t("fields.approvalNever") },
]}
/>
</Field>
</>
) : (
<Field label={t("fields.permissionMode")}>
<Select<PermissionMode>
value={props.permissionMode}
onChange={props.onPermissionModeChange}
options={[
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
{ value: "default", label: t("fields.permissionDefault") },
{ value: "plan", label: t("fields.permissionPlan") },
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
]}
/>
</Field>
)}
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
</div>
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
@@ -355,7 +621,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,
});
}
@@ -440,7 +709,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);
@@ -516,6 +788,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" />
)}
@@ -674,7 +948,7 @@ function SessionPicker({
s.status === "active"
? "bg-status-success/10 text-status-success border-status-success/30"
: s.status === "completed"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: s.status === "error"
? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-fg-secondary border-border"
+139 -5
View File
@@ -9,21 +9,86 @@
* The socket auto-reconnects: a server restart kills the attach PTY, and
* without a retry the pane silently freezes on its last painted frame — it
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
* and its PATH pasted into the pane — what a local terminal does on a drop,
* and how `claude` attaches an image.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
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;
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
// assumes a dark background. Read through a ref at construction so a theme
// change never re-runs the attach effect; the effect below recolours live.
const darkTheme = useTheme().themeFor.dark;
const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]);
const paletteRef = useRef(palette);
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
const { t } = useTranslation("run");
const [drop, setDrop] = useState<DropState | null>(null);
// dragenter/dragleave fire for every child crossed, so count them: the hint
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (state: DropState) => {
setDrop(state);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setDrop(null), 2500);
};
useEffect(
() => () => {
if (flashTimer.current) clearTimeout(flashTimer.current);
},
[]
);
const onDrop = async (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return flash("imagesOnly");
setDrop("uploading");
try {
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees one pasted path rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
flash("failed");
}
};
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -35,12 +100,21 @@ 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',
theme: paletteRef.current,
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
if (containerRef.current) term.open(containerRef.current);
@@ -63,6 +137,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 +194,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 +230,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 {
@@ -195,8 +283,54 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
ws?.close();
canvas?.dispose();
term.dispose();
termRef.current = null;
};
}, [runId, wsBaseUrl]);
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
useEffect(() => {
if (termRef.current) termRef.current.options.theme = palette;
}, [palette]);
return (
<div
className="relative h-full w-full"
onDragEnter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current += 1;
setDrop("hint");
}}
onDragOver={(e) => {
if (!hasFiles(e)) return;
e.preventDefault(); // without it the browser refuses the drop
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!hasFiles(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
>
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
{drop && (
// pointer-events-none: the drop must land on the terminal, not on this.
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "imagesOnly"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
</div>
)}
</div>
);
}
@@ -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>
),
}));
@@ -37,6 +42,7 @@ vi.mock("../../../lib/api", () => ({
const LANE: Lane = {
id: 1,
router_profile: null,
title: "demo",
cwd: "/workspace/a",
branch: null,
@@ -129,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) => ({
@@ -194,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",
@@ -242,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)", () => {
@@ -255,4 +331,126 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
const run = (id: string, laneId: number, promptPreview: string) => ({
id,
laneId,
status: "running",
cwd: "/workspace/x",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
});
const session = (id: string, cwd: string, name: string) =>
({
id,
cwd,
name,
model: null,
source: "local",
started_at: "2026-09-24T01:00:00Z",
}) as never;
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
lanes={[
LANE,
{ ...LANE, id: 2, cwd: "/workspace/b" },
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
]}
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
externalSessions={[
session("s1", "/workspace/a/src", "my session"),
session("s2", "/workspace/b", "b session"),
session("s3", "/workspace/a/nested/x", "nested session"),
session("s4", "/workspace/ab", "sibling prefix"),
]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.getByText("my session")).toBeTruthy();
expect(screen.queryByText("other lane's")).toBeNull();
expect(screen.queryByText("b session")).toBeNull();
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
describe("split panes on one lane", () => {
const run = (id: string, promptPreview = id) => ({
id,
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
tool: "claude" as const,
});
it("attaches a pane only to its own slot's session", async () => {
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-2"
)
);
});
it("starts into its own slot and shows the session the server opened", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "agenthub-lane-1" },
run_id: "agenthub-lane-1-1",
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
render(<LaneConsolePane {...baseProps()} slot={1} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ slot: 1 })
)
);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-1"
)
);
});
it("leaves runs other panes show out of Active runs", () => {
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
slot={1}
activeRuns={{
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
}}
hiddenRunIds={["agenthub-lane-1"]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
expect(screen.getByText("free")).toBeTruthy();
expect(screen.queryByText("shown")).toBeNull();
});
});
});
@@ -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(),
};
@@ -242,6 +244,14 @@ describe("ActiveRunsSwitcher", () => {
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
});
it("shows a dash, not the epoch, for a row with no start time", () => {
// A run whose persisted row is missing arrives with startedAt 0. Rendering
// that as "Jan 1, 08:00 AM" presents a gap in the data as real data.
renderModal([row("no-start", { startedAt: 0 })]);
expect(screen.getByText("-")).toBeTruthy();
expect(screen.queryByText(/Jan 1,/)).toBeNull();
});
it("fires attach with the run id of the row that was clicked", () => {
const { spies } = renderSwitcher();
openModal();
@@ -266,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")));
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { RunSetup } from "../RunSetup";
import type { CwdSuggestion } from "../../../lib/api";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
api: {
run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
ccConfig: { file: r({ text: "" }) },
ccConfig: {
file: r({ text: "" }),
commands: r({
items: [
{
scope: "project",
name: "ship-it",
frontmatter: { description: "Ship the change" },
preview: "",
},
],
}),
skills: r({
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
}),
},
},
};
});
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
busy={false}
activeRuns={null}
resumeSession={null}
slashCommands={[]}
runHistory={[]}
tool="claude"
sandbox="workspace-write"
@@ -121,8 +136,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 +251,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" });
@@ -277,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
busy={false}
activeRuns={null}
resumeSession={null}
slashCommands={[]}
runHistory={[]}
laneCwd="/Users/tester/lane-b"
tool="claude"
@@ -341,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
expect(onResumeLast).toHaveBeenCalled();
});
});
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
fireEvent.change(box(), { target: { value: "/rev" } });
// Both prefix matches; the shorter name ranks first.
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual([
expect.stringContaining("/review"),
expect.stringContaining("/review-pr"),
]);
fireEvent.keyDown(box(), { key: "ArrowDown" });
// Read for the directory the run will start in, not the server's own.
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
fireEvent.keyDown(box(), { key: "Enter" });
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
// Enter picked the suggestion; it did not send the prompt.
expect(spies.onStart).not.toHaveBeenCalled();
});
it("suggests files under the cwd on @", async () => {
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
// The prompt is controlled by the page: put the cursor at the end of it.
const { spies } = renderSetup({ prompt: "look at @ma" });
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
fireEvent.select(box());
fireEvent.click(await screen.findByText("src/main.ts"));
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
});
it("offers no slash commands on a codex lane", async () => {
renderSetup({ prompt: "", tool: "codex" });
fireEvent.change(box(), { target: { value: "/rev" } });
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
expect(api.ccConfig.commands).not.toHaveBeenCalled();
});
});
@@ -3,11 +3,12 @@
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* re-attaches after the socket drops (server restart) but not after `exit`.
* re-attaches after the socket drops (server restart) but not after `exit`,
* and recolours live on a dark-theme change without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
const writeMock = vi.fn();
@@ -16,6 +17,12 @@ const openMock = vi.fn();
const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropImageMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
const termState = {
@@ -25,9 +32,13 @@ const termState = {
rows: 28,
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn().mockImplementation(() => ({
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
...(ctorArgs.push(opts), {}),
options: termOptions,
get modes() {
return termState.modes;
},
@@ -54,8 +65,14 @@ vi.mock("@xterm/xterm", () => ({
loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
vi.mock("../../../lib/terminalTheme", () => ({
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
@@ -100,16 +117,49 @@ describe("TerminalView", () => {
keyHandlerMock.mockClear();
loadAddonMock.mockClear();
openMock.mockClear();
focusMock.mockClear();
disposeMock.mockClear();
ctorArgs.length = 0;
delete termOptions.theme;
localStorage.clear();
});
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");
});
it("paints the chosen DARK theme and recolours live without re-attaching", () => {
localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark
localStorage.setItem("agenthub.theme.dark", "apple-dark");
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" });
act(() => {
localStorage.setItem("agenthub.theme.dark", "darcula");
window.dispatchEvent(new Event("agenthub-theme"));
});
expect(termOptions.theme).toEqual({ background: "bg-darcula" });
expect(MockWebSocket.instances).toHaveLength(1);
expect(disposeMock).not.toHaveBeenCalled();
});
it("writes incoming WS data to the terminal", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
@@ -184,6 +234,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
@@ -252,4 +320,31 @@ describe("TerminalView", () => {
expect(MockWebSocket.instances).toHaveLength(1);
vi.useRealTimers();
});
it("pastes the server-side path of a dropped image into the pane", async () => {
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
const target = screen.getByTestId("terminal-view");
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
expect(dropImageMock).toHaveBeenCalledWith(img);
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("uploads nothing for a dropped non-image and says why", async () => {
dropImageMock.mockReset();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [txt] },
});
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
expect(dropImageMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
});
@@ -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();
});
});
@@ -151,7 +151,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x2", innerW)
.attr("y1", (d) => yScale(d))
.attr("y2", (d) => yScale(d))
.attr("stroke", "#2a2a3d")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1);
// Y axis (sessions)
@@ -166,7 +166,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
)
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -176,7 +176,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#9ca3af")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -185,7 +185,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", innerW / 2)
.attr("y", innerH + 38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -196,7 +196,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", -innerH / 2)
.attr("y", -38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -229,7 +229,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", bx + bw / 2)
.attr("y", by - 5)
.attr("text-anchor", "middle")
.attr("fill", "#a5b4fc")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
@@ -242,7 +242,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("y", innerH - 1)
.attr("width", bw)
.attr("height", 1)
.attr("fill", "#2a2a3d");
.attr("fill", "rgb(var(--border))");
}
// Transparent, full-height hover target on top of the bar.
@@ -364,12 +364,12 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
label={t("compaction.sessionsAffected")}
value={affected.toLocaleString()}
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
accent="text-violet-300"
accent="text-violet-700 dark:text-violet-300"
/>
<StatBox
label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)}
accent="text-blue-400"
accent="text-blue-700 dark:text-blue-400"
/>
<StatBox
label={t("compaction.peakSession")}
@@ -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,
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#10b981"
stroke="rgb(var(--status-success))"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
@@ -174,7 +174,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
</div>
<span
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
style={{ color: isHovered ? color : "#9ca3af" }}
style={{ color: isHovered ? color : "rgb(var(--fg-secondary))" }}
>
{d.count}
</span>
@@ -1,11 +1,11 @@
/**
* @file LanePipelinePanel.tsx
* @description How often each node of this dashboard's OWN pipelines actually
* gets passed, aggregated over every lane and every archived feature by
* gets passed, aggregated over every recorded run by
* `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed
* with evidence / passed without / failed, plus the raw counts, so a stage that
* everyone claims but nobody proves is visible at a glance. A lane picker
* narrows the same aggregation to one lane and its archived features.
* with evidence / passed without / failed / reached but only inferred, plus the
* raw counts, so a stage that everyone claims but nobody proves is visible at a
* glance. A lane picker narrows the same aggregation to one lane.
* Read-only — the live per-lane view and every lane action stay on the
* Workspace page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -101,7 +101,9 @@ export function LanePipelinePanel() {
);
}
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1);
// Detected-only reaches share the scale: a node the hooks saw fifty times and
// nobody ever declared should read as busy, not as empty.
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered + n.detected), 1);
return (
<div className="space-y-3">
@@ -127,6 +129,7 @@ export function LanePipelinePanel() {
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
<LegendSwatch className="bg-fg-muted/60" label={t("lanes.legendDetected")} />
</div>
</div>
@@ -178,11 +181,23 @@ export function LanePipelinePanel() {
style={{ width: `${pct(n.failed, maxEntered)}%` }}
title={t("lanes.legendFailed")}
/>
<div
className="h-full bg-fg-muted/60"
style={{ width: `${pct(n.detected, maxEntered)}%` }}
title={t("lanes.legendDetected")}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
{n.entered === 0 ? (
t("lanes.never")
// Reached but never declared still beats "never reached" — it
// is the difference between "you skip this stage" and "you do
// it without saying so".
n.detected > 0 ? (
t("lanes.detectedOnly", { count: n.detected })
) : (
t("lanes.never")
)
) : (
<>
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
@@ -587,7 +587,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("x", (_, i) => layerXPositions[i] ?? 0)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "10px")
.attr("font-weight", "500")
.attr("letter-spacing", "0.08em")
@@ -605,7 +605,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("y1", 32)
.attr("x2", sepX)
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
.attr("stroke", "#1f1f30")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
}
@@ -651,7 +651,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
@@ -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>
@@ -262,7 +262,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
// Grid lines
const gridColor = "#2a2a3d";
const gridColor = "rgb(var(--border))";
g.append("g")
.attr("class", "grid-x")
@@ -303,9 +303,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.tickFormat((d) => fmtXTick(d as number))
)
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// X axis label
@@ -313,7 +313,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", innerW / 2)
.attr("y", innerH + 44)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.duration"));
@@ -321,9 +321,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
g.append("g")
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// Y axis label
@@ -332,7 +332,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", -innerH / 2)
.attr("y", -40)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.agentCount"));
@@ -75,7 +75,7 @@ type AgentNode = SessionDrillInData["tree"][number];
function statusColor(status: string): string {
switch (status) {
case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
return "text-violet-700 dark:text-violet-400 bg-violet-500/10 border-violet-500/20";
case "working":
return "text-status-success bg-status-success/10 border-status-success/20";
case "error":
@@ -83,7 +83,7 @@ function statusColor(status: string): string {
case "active":
return "text-status-success bg-status-success/10 border-status-success/20";
case "waiting":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
return "text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
default:
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
}
@@ -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 */}
@@ -186,7 +186,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
{/* Name */}
<span
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
className={`text-sm font-medium truncate ${isMain ? "text-indigo-700 dark:text-indigo-300" : "text-fg-secondary"}`}
>
{node.name}
</span>
@@ -252,10 +252,10 @@ 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">
<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-700 dark:text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
{ev.tool_name ?? ev.event_type}
</span>
@@ -286,8 +286,8 @@ interface EventSequenceProps {
const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-500",
tool_result: "text-status-success",
agent_start: "text-indigo-400",
agent_stop: "text-violet-400",
agent_start: "text-indigo-700 dark:text-indigo-400",
agent_stop: "text-violet-700 dark:text-violet-400",
compaction: "text-status-warning",
error: "text-status-danger",
};
@@ -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" />
@@ -121,13 +121,13 @@ function statusClass(status: string): string {
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
// as its own color in both the filter row and the result label chips.
const PHASE_PALETTE = [
"bg-violet-500/15 text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-300 border-sky-500/40",
"bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/40",
"bg-status-warning/15 text-status-warning border-status-warning/40",
"bg-status-success/15 text-status-success border-status-success/40",
"bg-rose-500/15 text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
"bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/40",
];
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
@@ -321,7 +321,7 @@ export function WorkflowRunsPanel({
if (!controlled && loading) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
<Loader2 className="w-4 h-4 animate-spin text-violet-700 dark:text-violet-400" />
<span className="animate-pulse">{t("runs.loading")}</span>
</div>
);
@@ -365,7 +365,7 @@ export function WorkflowRunsPanel({
{running ? (
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
) : (
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@@ -393,7 +393,7 @@ export function WorkflowRunsPanel({
<Link
to={`/sessions/${encodeURIComponent(run.session_id)}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
className="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
title={t("runs.openSession")}
>
<ExternalLink className="w-3.5 h-3.5" />
@@ -565,7 +565,7 @@ export function WorkflowRunsPanel({
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
{ts?.loading && (
<span className="flex items-center gap-1 text-violet-400">
<span className="flex items-center gap-1 text-violet-700 dark:text-violet-400">
<Loader2 className="w-3 h-3 animate-spin" />
{t("runs.loadingFull")}
</span>
@@ -71,7 +71,7 @@ function formatDurationSec(sec: number): string {
function successRateColor(rate: number): string {
if (rate > 90) return "text-status-success";
if (rate > 70) return "text-yellow-400";
if (rate > 70) return "text-yellow-700 dark:text-yellow-400";
return "text-status-danger";
}
@@ -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>
@@ -325,7 +325,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgAgentDepth")}
value={stats.avgDepth.toFixed(1)}
icon={GitFork}
accentClass="text-indigo-400"
accentClass="text-indigo-700 dark:text-indigo-400"
calculationKey="stats.tooltip.calc.depth"
interp={interpAvgDepth(stats.avgDepth)}
metricPhraseKey="stats.tooltip.phrase.depth"
@@ -352,7 +352,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.mostCommonFlow")}
value={topFlowLabel}
icon={ArrowRightLeft}
accentClass="text-violet-400"
accentClass="text-violet-700 dark:text-violet-400"
calculationKey="stats.tooltip.calc.topFlow"
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
metricPhraseKey="stats.tooltip.phrase.topFlow"
@@ -361,7 +361,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgCompactions")}
value={stats.avgCompactions.toFixed(1)}
icon={Layers}
accentClass="text-cyan-400"
accentClass="text-cyan-700 dark:text-cyan-400"
calculationKey="stats.tooltip.calc.compactions"
interp={interpAvgCompactions(stats.avgCompactions)}
metricPhraseKey="stats.tooltip.phrase.compactions"
@@ -29,6 +29,7 @@ const node = (id: string, over: Record<string, number | string> = {}) => ({
passed: 0,
withEvidence: 0,
failed: 0,
detected: 0,
...over,
});
@@ -114,6 +115,24 @@ describe("LanePipelinePanel", () => {
const ship = await screen.findByTestId("pipeline-stat-ship");
expect(ship).not.toHaveTextContent("%");
expect(ship).toHaveTextContent(/never reached/i);
});
it("separates a node the hooks saw from one that never happened", async () => {
// `gate` was reached in two runs but never declared. Reporting that as
// "never reached" is what made the panel look broken to someone who works
// without running the pipeline commands.
statsMock.mockResolvedValue({
...STATS,
pipelines: STATS.pipelines.map((p, i) =>
i === 0 ? { ...p, nodes: [...p.nodes, node("gate", { detected: 2 })] } : p
),
});
render(<LanePipelinePanel />);
const gate = await screen.findByTestId("pipeline-stat-gate");
expect(gate).not.toHaveTextContent(/never reached/i);
expect(gate).toHaveTextContent("2");
});
it("switches templates through the picker", async () => {
@@ -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.ico", silent: false });
} else {
new Notification(title, { body, icon: "/favicon.ico" });
}
} 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;

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