Compare commits

..

119 Commits

Author SHA1 Message Date
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
315 changed files with 35978 additions and 10007 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
+143 -251
View File
@@ -39,8 +39,6 @@ Architectural overview and technical reference for the Agent Dashboard system, c
![React Testing Library](https://img.shields.io/badge/React_Testing_Library-13.0-FF5733?style=flat-square&logo=testinglibrary&logoColor=white)
![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white)
![Prettier](https://img.shields.io/badge/Prettier-3.8-F7B93E?style=flat-square&logo=prettier&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)
![Make](https://img.shields.io/badge/Make-4.3-000000?style=flat-square&logo=make&logoColor=white)
---
@@ -63,11 +61,10 @@ Architectural overview and technical reference for the Agent Dashboard system, c
- [Browser Notification System](#browser-notification-system)
- [Update Notifier Subsystem](#update-notifier-subsystem)
- [Sagi Companion Subsystem](#sagi-companion-subsystem)
- [VS Code Extension Architecture](#vs-code-extension-architecture)
- [Security Considerations](#security-considerations)
- [Performance Characteristics](#performance-characteristics)
- [Deployment Modes](#deployment-modes)
- [Statusline Utility](#statusline-utility)
- [Model Name Formatting](#model-name-formatting-client)
- [Technology Choices](#technology-choices)
- [Build & Run Targets](#build--run-targets)
@@ -347,14 +344,21 @@ graph TD
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race |
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus `POST /profiles/usage` (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see `cc-usage.js`), plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json` (on **macOS**: out of the login Keychain item `Claude Code-credentials`, which is what the CLI reads there — the file is only a fallback, and `AGENTHUB_CC_KEYCHAIN=0` pins it), and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
| `lib/cc-usage.js` | How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has `subscriptionType`/`rateLimitTier` and nothing else), so it asks `GET https://api.anthropic.com/api/oauth/usage`, the same endpoint the CLI's own `/usage` reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and **nothing here runs on a poll**. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through `cc-profiles.writeProfile()` because a dropped refresh leaves an account that needs a fresh `claude auth login`. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh |
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
| `lib/codex-cli.js` | Shells out to the `codex` CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (`mcp list\|add\|remove`), plugins (`plugin list\|add\|remove`), and marketplaces (`marketplace list\|add\|remove`). `runCodex()` parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with `stdout`/`stderr` preserved |
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
@@ -364,6 +368,8 @@ graph TD
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. Deliberately the boring half of git — no merge, rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
@@ -464,62 +470,44 @@ graph TD
### Splash & loading UX
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per theme, consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
### Self-hosted assets (no external CDN)
Nothing the dashboard or docs render is fetched from a third-party CDN at runtime — all fonts and scripts are served locally, so every surface works fully offline and leaks nothing to external hosts.
Nothing the dashboard renders is fetched from a third-party CDN at runtime — fonts are served locally, so the UI works fully offline and leaks nothing to external hosts.
- **React app fonts** — Inter + JetBrains Mono are imported from `@fontsource` (latin subset) in `client/src/main.tsx`. Vite bundles the per-weight WOFF2 into `client/dist/assets/` with content hashes at build time; there is no Google Fonts `<link>`. Importing the `latin-*` subset entry points keeps the emitted set to one WOFF2 per weight.
- **Static pages (landing + wiki)** — load a self-hosted `@font-face` sheet at the repo-root `fonts/` directory (`fonts/fonts.css` + the `*.woff2` files). The root `index.html` references `fonts/fonts.css`; the wiki references `../fonts/fonts.css` (relative paths resolve under GitHub Pages).
- **Wiki Mermaid** — vendored as `wiki/mermaid.min.js` (the genuine minified `mermaid@10.9.6` from npm, with a provenance banner; `.prettierignore`d) and loaded via a local `<script>` instead of `cdn.jsdelivr.net`.
- **VS Code extension** — the inline `getErrorHtml()` error page dropped its Google Fonts loader for a system font stack (no bundler / local font path available in that webview).
Net effect: no `fonts.googleapis.com`, `fonts.gstatic.com`, or `cdn.jsdelivr.net` requests anywhere (verified by `git grep`).
Net effect: no `fonts.googleapis.com` or `fonts.gstatic.com` requests anywhere (verified by `git grep`).
### PWA Architecture
The project ships three independent Progressive Web Apps. Each has its own Web App Manifest and Service Worker, so the browser treats them as separate installable applications with isolated caches.
The dashboard ships as a Progressive Web App with its own Web App Manifest and
Service Worker (`client/public/manifest.json` + `client/public/sw.js`), so the
browser treats it as an installable application with its own cache.
```
┌─────────────────────────────────────────────────────────────────┐
│ PWA Surface Map │
├──────────────────┬──────────────────┬───────────────────────────┤
│ Dashboard │ Landing Page │ Wiki │
│ (client/) │ (root) │ (wiki/) │
├──────────────────┼──────────────────┼───────────────────────────┤
│ manifest.json │ manifest.json │ manifest.json │
│ sw.js │ sw.js │ sw.js │
│ id: dashboard │ id: landing │ id: wiki │
├──────────────────┼──────────────────┼───────────────────────────┤
│ Precache: │ Precache: │ Precache: │
│ /, manifest, │ index.html, │ index.html, style.css, │
│ favicon.svg │ favicon, og-img, │ script.js, manifest, │
│ │ manifest │ favicon │
│ Runtime cache: │ Runtime cache: │ Runtime cache: │
│ JS/CSS bundles │ screenshot PNGs │ (all precached) │
│ (cache-first) │ (cache-first) │ │
│ │ │ │
│ Skip: /api/*, │ N/A │ N/A │
│ /ws, __vite │ │ │
│ │ │ │
│ + Push notifs │ │ │
│ (VAPID pipeline) │ │ │
└──────────────────┴──────────────────┴───────────────────────────┘
```
**Service Worker lifecycle (all three):**
**Service Worker lifecycle:**
1. **Install** → `skipWaiting()` — new SW activates immediately, no waiting for tabs to close.
2. **Activate** → old caches deleted (keyed by `CACHE_NAME`: `dashboard-v1`, `landing-v1`, `wiki-v1`). Bump the version string to force a cache bust.
2. **Activate** → old caches deleted (keyed by `CACHE_NAME`, e.g. `dashboard-v2`). Bump the version string to force a cache bust.
3. **Fetch** → Navigation requests are network-first with offline fallback to cached HTML. Static assets are cache-first with runtime caching on miss.
**Dashboard SW specifics:** The fetch handler skips `/api/*`, `/ws`, and Vite HMR (`__vite`) URLs so live data and development tooling are never cached. Only responses with `response.type === "basic"` (same-origin) are stored. The existing push notification handlers (`push`, `notificationclick`) are preserved alongside the caching logic.
The fetch handler skips `/api/*`, `/ws`, and Vite HMR (`__vite`) URLs so live data
and development tooling are never cached. Only responses with
`response.type === "basic"` (same-origin) are stored. The push notification
handlers (`push`, `notificationclick`) live alongside the caching logic — their
payload icon is the same-origin `/favicon.svg` (see `server/lib/push.js`).
**Manifest icons:** All three manifests reference `favicon.svg` with `sizes="any"` and `type="image/svg+xml"` — supported in Chrome 107+, Firefox 110+, Edge 107+. Two icon entries per manifest: one with `purpose: "any"` and one with `purpose: "maskable"`.
**Manifest icons:** `favicon.svg` with `sizes="any"` and `type="image/svg+xml"` —
supported in Chrome 107+, Firefox 110+, Edge 107+. Two icon entries: one with
`purpose: "any"` and one with `purpose: "maskable"`.
**iOS meta tags:** All HTML files include `<meta name="apple-mobile-web-app-capable" content="yes">` and `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">` for standalone home-screen mode on Safari.
**iOS meta tags:** `client/index.html` includes
`<meta name="apple-mobile-web-app-capable" content="yes">` and
`<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`
for standalone home-screen mode on Safari.
### Client Module Graph
@@ -597,10 +585,13 @@ graph LR
DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail]
ACT_R["/activity"] --> ACT[ActivityFeed]
WF_R["/workflows"] --> WF[Workflows]
CC_R["/cc-config"] --> CC[CcConfig]
CC_R["/cc-config"] -->|redirect| SET_R
LANES_R["/lanes"] -->|redirect| RUN_R["/run"]
RUN_R --> WORKSPACE["Workspace<br/>lanes + runs merged"]
SET_R["/settings"] --> SET[Settings]
SET_R["/settings"] --> SETHUB["SettingsHub<br/>(?tool= tab shell)"]
SETHUB --> SETDASH["dashboard tab<br/>Settings"]
SETHUB --> SETCC["claude-code tab<br/>CcConfig"]
SETHUB --> SETCODEX["codex tab<br/>CodexConfig"]
NF_R["/*"] --> NF[NotFound]
ALL["All routes"] --> LAYOUT["Layout wrapper<br/>(Sidebar + Outlet)"]
@@ -613,9 +604,9 @@ graph LR
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only |
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
| `/*` | NotFound | None (static 404 page) |
### Activity Feed Interaction Model
@@ -1049,7 +1040,7 @@ flowchart TD
- Uses Node.js `http` module directly — no dependencies.
- Resolution order is **env override → discovery file → default**:
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when Docker and `npm run dev` share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel.
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel.
- If neither yields anything, the handler falls back to `4820`.
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
@@ -1590,15 +1581,14 @@ last node carries `detect` rules, so a lane can never be auto-declared `done`.
### Codex evidence reliability (`server/lib/codex-nudge.js`)
Auto-advance moves the ring but can never supply evidence; only the session's
own `agenthub stage --evidence` call can, so three mechanisms make that more likely
without weakening declared-wins/inferred-never-`done`:
own `agenthub stage --evidence` call can, so two mechanisms make that more likely
without weakening declared-wins/inferred-never-`done`. (Opening-prompt priming
used to be a third: `LaneConsolePane.tsx` pre-filled the prompt box from the
lane's pipeline — the driving slash command on a Claude lane, a plain-text
stage checklist on a codex one. It is gone: whatever sits in that box is sent
as a real prompt when the run starts, so the box now stays empty and the
prompt is the user's alone.)
- **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex
pane's first prompt is pre-filled with a plain-text checklist of
`agenthub stage <stage> --tool codex` calls, one per node of `Lane.pipeline_nodes`
— the same up-front nudge a Claude lane gets from its pre-filled
`/agenthub:ship-feature-lane` skill command, in plain text since Codex has no
slash commands.
- **`AGENTS.md` stage block** (`server/lib/lane-agents.js`): on a `codex` lane,
`agenthub lanes agents install` writes a marker-delimited
`<!-- agenthub:stage:start -->` section into `<lane cwd>/AGENTS.md` instead of
@@ -1772,7 +1762,7 @@ Skills are namespaced: `/agenthub-analytics:session-report`, `/agenthub-producti
| **agenthub-config** | `config-audit`, `memory-review`, `skill-inventory`, `mcp-audit`, `hook-inventory` | `config-auditor` | — | — |
| **agenthub-dashboard** | `dashboard-status`, `quick-stats` | — | — | — |
**Totals**: 10 plugins, 53 skills, 14 agents, 30 slash commands, 3 CLI tools, 3 hook configurations. Each plugin is installable via `claude plugin install <name>@smartgift-claude-code-monitor`, and a server test (`server/__tests__/plugins-marketplace.test.js`) validates the marketplace↔directory bijection plus every `plugin.json`, agent, skill, and command.
**Totals**: 10 plugins, 53 skills, 14 agents, 30 slash commands, 3 CLI tools, 3 hook configurations. Each plugin is installable via `claude plugin install <name>@agenthub`, and a server test (`server/__tests__/plugins-marketplace.test.js`) validates the marketplace↔directory bijection plus every `plugin.json`, agent, skill, and command.
### Data Model Grounding
@@ -1801,8 +1791,8 @@ Plugins compute these from raw API data:
```bash
# Marketplace install
claude plugin marketplace add Smartgift-AI/Claude-Code-Monitor
claude plugin install agenthub-analytics@smartgift-claude-code-monitor
claude plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
claude plugin install agenthub-analytics@agenthub
# Local development testing
claude --plugin-dir plugins/agenthub-analytics
@@ -1926,7 +1916,7 @@ graph TD
| Component | Responsibility |
| --- | --- |
| **VAPID Pipeline** | Uses the `web-push` library on the server. VAPID keys are auto-generated on first run and persisted to `data/vapid-keys.json` to ensure subscription continuity. |
| **Service Worker** | Located at `client/public/sw.js`. It runs independently of the dashboard tab, listening for `push` events from the browser's push service. It handles `notificationclick` to focus/open the dashboard. |
| **Service Worker** | Located at `client/public/sw.js`. It runs independently of the dashboard tab, listening for `push` events from the browser's push service. It handles `notificationclick` by navigating (`client.navigate`, falling back to `clients.openWindow`) an existing dashboard tab to the notification's `data.url` before focusing it — so a click lands on the lane that raised it, not just whichever tab happened to be open. |
| **macOS Audio Support** | Notifications are explicitly sent with `silent: false` and `sound: "default"`. This overrides macOS behavior that would otherwise suppress audio for web notifications. |
| **Subscription Management** | The dashboard registers the service worker and requests a `PushSubscription`. This subscription (endpoint and keys) is stored in the `push_subscriptions` table, indexed by endpoint. |
| **Event Routing** | When a WebSocket event (e.g., `session_created`) is broadcast, the server also triggers `sendPushToAll()`, which iterates through active subscriptions and sends signed VAPID payloads. |
@@ -1947,6 +1937,16 @@ flowchart TD
style SHOW fill:#10b981,stroke:#34d399,color:#fff
```
### Delivery fallback
`POST /api/push/send` answering `200` means the request was **accepted**, not that anyone was notified: it reports `{ok, native, pushed, failed}`, and a browser that never subscribed (no service worker, a non-secure origin such as `http://<lan-ip>:4820`, a push service the server cannot reach) gets `native:false, pushed:0`. `notify()` in `useNotifications.ts` therefore treats "delivered" as `native || pushed > 0` and otherwise shows the notification locally through the service worker / `Notification` — a fetch that merely resolved is not proof. The fallback is skipped when the push did land, so nothing is shown twice.
Note the browser-side floor this cannot lift: the `Notification` API needs a secure context, so open the dashboard on `http://localhost` (secure by definition) or behind HTTPS — on a bare LAN IP no notification of any kind is possible.
### Click-through to the lane
Every `notify()` call in `useNotifications.ts` resolves a per-event `url` — `/workspace?lane=<id>` — by matching the event's `session_id` against a lane whose `session_id` currently equals it (a lightweight lane cache the hook keeps synced off `lane_update` broadcasts, seeded once via `GET /api/lanes`). `POST /api/push/send`'s optional `url` field carries that path through the VAPID payload as `data.url`; `sw.js`'s `notificationclick` handler reads it back to navigate. The `Workspace` page consumes `?lane=<id>` in a one-shot effect (selects the lane, then strips the param via `replace: true` so it doesn't override a later manual lane switch). No lane match (event predates lane tracking, or the lane was removed) — `url` stays `undefined` and the click just focuses the dashboard, the prior behavior.
### Preference Storage
Notification preferences remain in `localStorage` (`agent-monitor-notifications`) for UI-side filtering, while the actual push delivery is managed by the server-side subscription store.
@@ -1954,10 +1954,12 @@ Notification preferences remain in `localStorage` (`agent-monitor-notifications`
| Preference | UI Key | Logic |
| --- | --- | --- |
| Master Toggle | `enabled` | Controls whether the subscription is active |
| New Session | `onNewSession` | Filtered during push fan-out |
| Session Error | `onSessionError` | Filtered during push fan-out |
| Session Complete | `onSessionComplete` | Filtered during push fan-out |
| Subagent Spawn | `onSubagentSpawn` | Filtered during push fan-out |
| New Session | `onNewSession` | Filtered client-side in `useNotifications` |
| Session Error | `onSessionError` | Filtered client-side in `useNotifications` |
| Awaiting Input | `onAwaitingInput` | `Notification` events — Claude is blocked on the user. Default ON |
| Turn Complete | `onTurnComplete` | `Stop` events — Claude finished a turn. Default ON |
| Session Complete | `onSessionComplete` | `SessionEnd` events. Filtered client-side |
| Subagent Spawn | `onSubagentSpawn` | Filtered client-side in `useNotifications` |
### Service Worker Caching
@@ -1977,15 +1979,26 @@ These behaviors are reinforced by explicit `Cache-Control` headers from the prod
## Update Notifier Subsystem
The Update Notifier is a **detection-only** subsystem that tells the user when the dashboard's git checkout is behind its tracked upstream branch. It never mutates the checkout or restarts the server — those actions are intentionally left to the user in a terminal, because a process cannot reliably replace itself without an external supervisor.
The Update Notifier tells the user when a newer **release** of the dashboard has been published, and — on an ordinary git checkout that can be fast-forwarded — can apply it on one click.
The version signal is the forge's own release list, read over REST at `/api/v1/repos/<owner>/<repo>/releases/latest` (no hardcoded host, no credentials for a public repo). The owner and repo come from one of **two** sources, chosen by install kind: a checkout derives them from its `origin` remote; a plugin-cache install derives them from the marketplace manifest on disk (`pluginMarketplaceUrl`), because it has no remote to read — and often no `.git` at all, since a marketplace entry with `"source": "url"` (this project's own) makes Claude Code unpack an archive rather than clone. The published tag is compared against this install's `package.json` version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in `release_check`, so "up to date" never means "we couldn't look".
Two states the release model makes visible that a commit count cannot:
- **`dev_build`** — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the Sidebar says so instead of "Up to date".
- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **This branch is evaluated before the `.git` probe, and must stay there.** An archive-sourced cache directory is not a clone, so a `.git` check reached first answers "not a git clone; check manually" and skips the release layer entirely — which is exactly what 1.5.1 shipped: `update_available` pinned false however many releases were published, `plugin_marketplace`/`plugin_name` never set, so `/apply`'s plugin branch could never fire and its 409 claimed nothing newer was published. It reads as "up to date", so it stays invisible while the install happens to be current. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update <mkt>` → `claude plugin update <plugin>@<mkt> -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`.
A **shallow clone** (`--depth`) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All `commits_*_release` fields are `null` there and `dev_build` is never asserted; the version string is the only signal used.
### Module Layout
```mermaid
graph TD
subgraph Server
LIB["update-check.js<br/>getUpdatesStatus"]
LIB["update-check.js<br/>getUpdatesStatus + applyUpdate<br/>forge releases API"]
SCHED["update-scheduler.js<br/>startUpdateScheduler"]
ROUTE["routes/updates.js<br/>GET status, POST check"]
ROUTE["routes/updates.js<br/>GET status, POST check, POST apply"]
PLUG["lib/plugin-update.js<br/>schedulePluginUpdate"]
HELPER["scripts/plugin-update-helper.js<br/>detached: claude plugin update<br/>then plugin-bootstrap --force"]
WS["websocket.js<br/>broadcast update_status"]
end
@@ -1998,6 +2011,9 @@ graph TD
SCHED -->|tick every 5 min| LIB
ROUTE -->|on request| LIB
ROUTE -->|apply, plugin install| PLUG
PLUG -->|spawn detached| HELPER
HELPER -->|verdict file| LIB
SCHED -->|fingerprint changed| WS
ROUTE -->|on POST check| WS
WS -->|update_status frame| API
@@ -2057,11 +2073,12 @@ sequenceDiagram
| Component | Responsibility |
| --- | --- |
| **`server/lib/update-check.js`** | Pure function `getUpdatesStatus(root?, { skipFetch? })`. Runs every git call via `execFile` (no shell, 10s–120s timeouts). **Branch- and fork-aware:** prefers `upstream` over `origin` when both exist (standard fork convention), resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation: `git pull --ff-only` only when the local branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks (local branch name matches canonical but tracks a different remote); `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. Adds `situation`, `situation_note`, `canonical_remote`, `current_branch`, `tracking_upstream`, and `tracks_canonical` to the response. |
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
| **`server/routes/updates.js`** | Two endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast). Mounted under `/api/*`, so they ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. There is **no** `POST /apply` route. |
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps `dismissedSha` in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
| **`Sidebar.tsx`** | Always-visible "Check for updates" button in the footer. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. |
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
| **`Sidebar.tsx`** | Always-visible "Check for updates" button in the footer. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
### Payload Shape
@@ -2076,6 +2093,8 @@ interface UpdateStatusPayload {
tracking_upstream?: string | null; // e.g. "origin/feature/foo", null if no upstream
tracks_canonical?: boolean; // true when branch upstream === remote_ref
situation?: // categorical hint for the UI
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
| "release_available" // fast-forward onto the release tag
| "tracking_canonical"
| "fork_or_diverged_tracking"
| "feature_branch"
@@ -2087,34 +2106,73 @@ interface UpdateStatusPayload {
manual_command?: string | null; // shaped for the user's situation
message?: string | null;
fetch_error?: string; // set when git fetch fails
// ── Release layer ──
install_kind?: "plugin" | "checkout";
plugin_marketplace?: string | null; // "sg" — parsed from the cache path
plugin_name?: string | null; // "agenthub" — same source
last_plugin_update?: { // verdict of the previous chain, if any
ok: boolean;
step: string; // "validate" | "resolve_claude" | ...
error?: string;
note?: string;
plugin_root?: string;
finished_at?: string;
} | null;
local_version?: string | null; // this install's package.json version
shallow_clone?: boolean; // true => every commits_* below is null
release_check?: string; // "ok" | "no_remote" | "remote_not_http"
// | "no_releases" | "unreachable"
// | "bad_payload" | "http_<status>"
latest_release?: string | null; // tag, e.g. "v1.5.0"
latest_release_name?: string | null;
latest_release_url?: string | null;
latest_release_published_at?: string | null;
latest_release_notes?: string | null; // truncated server-side to 4000 chars
release_sha?: string | null; // commit the tag resolves to locally
commits_behind_release?: number | null;
commits_ahead_of_release?: number | null;
dev_build?: boolean; // ahead of the newest release, missing nothing
}
```
The same shape is used by `GET /status`, `POST /check`, and the `update_status` WS message.
The same shape is used by `GET /status`, `POST /check`, and the `update_status` WS message. `POST /apply` returns it plus `applied: boolean`; in plugin mode it also carries `mode: "plugin"` and `runtime_dir`, and on a 409 a `reason` of `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. `update_available` and `dev_build` are never both true.
### Failure Mode Matrix
| Condition | Returned payload | User-visible effect |
| --- | --- | --- |
| Not a git clone | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. |
| Not a git clone (and not a plugin cache) | `{git_repo:false, update_available:false, message:"Install directory is not a git clone..."}` | Modal suppressed (`update_available` false). Sidebar stays neutral. |
| Plugin cache with no `.git` (archive-sourced marketplace entry) | `{git_repo:false, install_kind:"plugin", release_check:"ok", latest_release:"vX.Y.Z", update_available:<version compare>}` | Checked normally: the repo URL comes from the marketplace manifest and `package.json` is the only local signal. The `notGit` note is suppressed and **Update now** is offered. |
| No remotes configured | `{git_repo:true, update_available:false, message:"No git remotes configured..."}` | Same as above. |
| `git fetch` failed (offline, auth) | `{git_repo:true, update_available:false, canonical_remote, fetch_error:"<stderr>"}` | Sidebar button goes amber; modal stays suppressed until a successful check. |
| Canonical default branch unresolvable | `{git_repo:true, update_available:false, canonical_remote, message:"Could not resolve <remote>/master..."}` | Modal suppressed. |
| Healthy, up to date | `{git_repo:true, update_available:false, commits_behind:0, situation:"tracking_canonical"\|...}` | Sidebar neutral, modal suppressed. |
| Release lookup failed (offline, non-HTTP remote, no releases) | `{release_check:"unreachable"\|"remote_not_http"\|"no_releases"\|..., latest_release:null}` | Detection falls back to the commit count; the modal states the reason so "up to date" is not read as "release checked". |
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
### Why Detection-Only
### Why Self-Apply Stays Narrow
The dashboard does not expose an apply/restart endpoint by design. A process cannot reliably replace itself without an external supervisor, and several real constraints make an in-process self-update path strictly worse than letting the user run two commands in a terminal:
`POST /api/updates/apply` is the one path in this dashboard that mutates its own working tree and restarts the process. It is deliberately narrow: in checkout mode it acts only on `release_available`, `tracking_canonical`, and `fork_or_diverged_tracking`, always with `--ff-only`, and refuses outright on a feature branch or a detached HEAD. A `plugin_managed` install is not a git operation at all — it is delegated to `claude plugin update`, whose own tooling owns that directory. Everywhere else the dashboard prints a command and lets the user run it, because the constraints below are real and an automatic path cannot answer them:
- **Supervisor ambiguity.** `npm run dev` (concurrently), `npm start`, `pm2`, `systemd`, `launchd`, and Docker each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest.
- **Supervisor ambiguity.** `npm run dev` (concurrently), `npm start`, `pm2`, `systemd`, and `launchd` each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest.
- **Silent failures.** `npm install` / `npm run build` / port-release timing issues surface as a dead server with no user-facing feedback once the original process has exited.
- **No rollback.** A partial pull + install leaves a broken checkout with no atomic recovery — the working tree is mutated mid-flight.
- **Branch coverage.** Even with the situation-aware `manual_command` produced by the detection layer, an automatic apply would still need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon.
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
The detection layer carries all of the signal value: the dashboard tells the user *when* to update and *exactly what to run*; the user owns the *how* in their own shell.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
---
@@ -2217,64 +2275,6 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
---
## VS Code Extension Architecture
The **AgentHub** VS Code extension provides an integrated monitoring experience directly within the editor. It communicates with the local dashboard server via standard HTTP APIs and renders the dashboard UI in a webview.
<p align="center">
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
</p>
### Extension Components
| Component | Responsibility |
| --- | --- |
| **Extension Host** (`extension.js`) | Manages the extension lifecycle, registers commands, creates the status bar item, and coordinates the webview panel. |
| **Sidebar Provider** (`sidebar.js`) | Implements the `TreeDataProvider` for the Activity Bar. It performs background polling of the dashboard APIs (`/api/stats`, `/api/analytics`, `/api/sessions`) every 5 seconds. |
| **Status Bar Item** | Provides a persistent "Pulse" indicator in the VS Code status bar, showing active session and agent counts. |
| **Webview Panel** | A native VS Code tab that renders the dashboard React application. It supports deep linking to specific sessions or sub-pages. |
### Data Flow
```mermaid
graph TD
subgraph "VS Code Process"
SB[Status Bar]
SIDE[Sidebar TreeView]
WV[Webview Panel]
end
subgraph "Extension Host"
EH[extension.js]
SP[sidebar.js]
end
subgraph "Dashboard Server (localhost)"
API["/api/stats<br/>/api/analytics<br/>/api/sessions"]
DS[Express Server]
end
SIDE -->|Poll 5s| SP
SP -->|HTTP GET| API
EH -->|HTTP GET| API
SB -->|Pulse| EH
WV -->|Iframe src| DS
```
### Key Implementation Details
1. **Auto-Detection**: The extension checks both port `5173` (Vite dev server) and `4820` (Production server) on `localhost`. It prioritizes the production port for API data but can render the UI from either.
2. **Real-time Status**: The `SidebarProvider` uses a background loop with `onDidChangeTreeData` to automatically toggle between **Online** and **Offline** states as the local server starts or stops.
3. **Deep Linking**: Commands like `claude-code-agent-monitor.openDashboard` accept arguments (e.g., a session ID or page path like `analytics`) to route the webview to specific views within the React SPA.
4. **Theme Awareness**: The Activity Bar icon (`icon.svg` or `apple-touch-icon.png`) and sidebar icons use VS Code's `ThemeIcon` and `ThemeColor` to ensure they adapt to Light, Dark, and High Contrast themes.
For the extension source code, refer to the [vscode-extension/](./vscode-extension/) directory.
> [!TIP]
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
---
## Security Considerations
| Area | Approach |
@@ -2361,59 +2361,7 @@ graph LR
| **File watching** | `node --watch` + Vite HMR | None |
| **Source maps** | Inline | External files |
### Container (Docker / Podman)
A multi-stage `Dockerfile` builds the client and server into a single production image. Both Docker and Podman are fully supported — the image is OCI-compliant.
```mermaid
graph LR
subgraph "Multi-Stage Build"
S1["Stage 1: server-deps\nnode:22-alpine\nnpm ci --omit=dev"]
S2["Stage 2: client-build\nnode:22-alpine\nnpm ci + vite build"]
S3["Stage 3: runtime\nnode:22-alpine\nCopies node_modules + client/dist"]
S1 --> S3
S2 --> S3
end
subgraph "Container Runtime"
VOL1["~/.claude (ro)\nlegacy session import"]
VOL2["agent-monitor-data\nSQLite persistence"]
S3 -->|"EXPOSE 4820"| SRV["node server/index.js\nport 4820"]
VOL1 --> SRV
VOL2 --> SRV
end
style S3 fill:#339933,stroke:#5cb85c,color:#fff
style SRV fill:#6366f1,stroke:#818cf8,color:#fff
```
**Usage:**
```bash
# Docker Compose
docker compose up -d --build
# Podman Compose
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
# Plain Docker / Podman (equivalent)
docker build -t agent-monitor .
docker run -d -p 4820:4820 \
-v "$HOME/.claude:/root/.claude:ro" \
-v agent-monitor-data:/app/data \
agent-monitor
```
> [!NOTE]
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
---
## Statusline Utility
The `statusline/` directory contains a standalone CLI statusline for Claude Code, separate from the web dashboard. It renders a color-coded bar at the bottom of the Claude Code terminal showing model, user, working directory, git branch, context window usage, per-direction token counts, and session cost in USD.
### Model Name Formatting (Client)
## Model Name Formatting (Client)
The `client/src/lib/format.ts` module exports a `formatModelName()` utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere **except** the Settings page, which shows raw patterns for pricing rule configuration).
@@ -2440,60 +2388,6 @@ The function handles:
Components that consume this: SessionDetail, Analytics (donut chart + breakdown), Dashboard (model stats), MessageList, AgentCard, SessionDrillIn, and EventDetail.
### Data Flow
```mermaid
sequenceDiagram
participant CC as Claude Code
participant SH as statusline-command.sh
participant PY as statusline.py
participant GIT as git CLI
CC->>SH: stdin (JSON payload)
SH->>PY: Pipes stdin through
PY->>PY: Parse JSON (model, cwd, context_window, cost)
PY->>GIT: git symbolic-ref --short HEAD
GIT-->>PY: Branch name
PY->>PY: Build ANSI-colored segments (incl. tokens by direction, cost)
PY-->>CC: stdout (formatted statusline)
```
### Segments
| Segment | Source | Color Logic |
| ------------ | ------------------------------------- | ------------------------------------------------------------------------------------ |
| Model | `data.model.display_name` | Always cyan |
| User | `$USERNAME` / `$USER` env var | Always green |
| Working Dir | `data.workspace.current_dir` | Always yellow, `~` prefix for home |
| Git Branch | `git symbolic-ref --short HEAD` | Always magenta, hidden outside git repos |
| Context Bar | `data.context_window.used_percentage` | Green < 50%, Yellow 50–79%, Red >= 80% |
| Token Counts | `data.context_window.current_usage` | Green `↑` input, cyan `↓` output, dim `c` cache reads |
| Session Cost | `data.cost.total_cost_usd` | Green < $5, Yellow $5–$20, Red >= $20 (shown on API and subscription plans) |
### Integration
The statusline is configured in `~/.claude/settings.json` via the `statusLine` key:
```json
{
"statusLine": {
"type": "command",
"command": "bash \"/path/to/.claude/statusline-command.sh\""
}
}
```
Claude Code invokes this command on each update, piping a JSON payload to stdin. The script reads the JSON, extracts fields, runs `git` for branch info, and prints ANSI-formatted output to stdout.
**Design decisions:**
- **Python 3.6+** -- available on virtually all systems, handles ANSI and JSON natively
- **No dependencies** -- uses only stdlib (`sys`, `json`, `os`, `subprocess`)
- **Shell wrapper** -- `statusline-command.sh` sets `PYTHONUTF8=1` for Windows Unicode support and resolves the absolute path to the Python script
- **Fail-safe** -- exits silently on empty input or JSON parse errors, never blocks Claude Code
---
## Technology Choices
| Technology | Why This Over Alternatives |
@@ -2521,8 +2415,6 @@ make build Build the React client for production
make start Start the production server
make test Run all tests (server + client)
make format Format all files with Prettier
make docker-up Start via docker-compose
make docker-down Stop docker-compose stack
```
See `Makefile` for the complete set of 25 targets covering setup, dev, testing, formatting, data management, Codex extensions, and Docker/Podman workflows.
See `Makefile` for the complete set of 17 targets covering setup, dev, testing, formatting, and data management.
+12 -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"]
+80 -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,69 @@ 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.
---
## 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 +137,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 +172,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 +273,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 +313,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
+99 -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,79 @@ 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).
- **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.
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 +146,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 +161,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 +226,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 +249,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 +285,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
+96 -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,86 @@ 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 đó.
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 +127,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 +141,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",
};
+29 -2
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)
@@ -55,6 +53,16 @@ The client is a single-page application (SPA) built with modern web technologies
- **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
**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
@@ -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
+11 -11
View File
@@ -3,13 +3,13 @@
<head>
<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 +23,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 +33,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 +51,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 +61,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>
+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 -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>
);
})}
+36 -14
View File
@@ -54,7 +54,6 @@ import { useTranslation } from "react-i18next";
import {
LayoutDashboard,
FolderOpen,
Activity,
BarChart3,
Workflow,
Play,
@@ -92,6 +91,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;
@@ -353,15 +359,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 +403,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>
+9 -5
View File
@@ -4,6 +4,13 @@
* by {@link StatCard} for raw metric values and anywhere a compact display needs
* a full-precision expansion without cluttering the layout.
*
* ## Look
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
* are visibly one thing. It stays cursor-following (and not anchored like the
* global one) because it expands a NUMBER inside a line of text, where the
* anchor is a few characters wide and the cursor is the real subject.
*
* ## Portal rendering
* Tooltip content is portaled to `document.body` with `position: fixed` so
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
@@ -56,6 +63,7 @@
import { useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { TOOLTIP_SURFACE } from "./Tooltip";
/** Props for {@link Tip}. */
interface TipProps {
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
{children}
{show &&
createPortal(
<div
ref={tipRef}
style={tipStyle}
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
>
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
{raw}
</div>,
document.body
+200
View File
@@ -0,0 +1,200 @@
/**
* @file Tooltip.tsx
* @description The dashboard's tooltip look, and the one renderer that gives it
* to every control in the app.
*
* ## Why a global renderer instead of a wrapper component
* The UI already labels its icon-only controls with the native `title`
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
* touching every one of those call sites to get a designed bubble, and the
* next `title=` someone writes would be back to the browser's grey box.
* {@link GlobalTooltip} instead listens once at the document: on hover or
* keyboard focus it takes the element's `title`, suppresses the native tooltip
* by removing the attribute for as long as the bubble is up, and restores it
* on the way out. Nothing else in the app has to know it exists, and a control
* keeps its native tooltip (and its accessible name) when JS has not run.
*
* ## The look
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
* hard to read and lands over the thing it describes. It flips above/below and
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
* after a short delay so a cursor crossing a toolbar does not strobe, and is
* built from the theme's own tokens so it is legible in light mode too (the
* old bubble was a hardcoded near-black).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
export const TOOLTIP_SURFACE =
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
"whitespace-pre-wrap break-words";
/** How long the pointer must rest on a control before its tooltip appears. */
const SHOW_DELAY_MS = 350;
/** Gap between the control and the bubble, and the minimum viewport margin. */
const GAP = 8;
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */
const SELECTOR = "[title]:not([data-tip-off])";
type Anchored = { text: string; rect: DOMRect };
type Placed = { left: number; top: number; arrow: number; above: boolean };
/**
* Mount ONCE, near the router root. Every `title` in the app becomes a
* designed tooltip; nothing else has to change.
*/
export function GlobalTooltip() {
const [tip, setTip] = useState<Anchored | null>(null);
const [pos, setPos] = useState<Placed | null>(null);
const bubble = useRef<HTMLDivElement>(null);
// The element whose `title` is currently parked in `data-tip`, so it can be
// put back even if the pointer never leaves it (a route change, a re-render
// that unmounts it, Escape).
const parked = useRef<HTMLElement | null>(null);
const timer = useRef<number | undefined>(undefined);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
const el = parked.current;
if (el) {
const text = el.dataset.tip;
if (text !== undefined) {
el.setAttribute("title", text);
delete el.dataset.tip;
}
parked.current = null;
}
setTip(null);
setPos(null);
}, []);
const open = useCallback(
(el: HTMLElement, delay: number) => {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
timer.current = window.setTimeout(() => {
// Re-read at fire time: the control may have been re-rendered, removed
// or had its title changed while the delay was running.
if (!el.isConnected) return;
const current = (el.getAttribute("title") || "").trim();
if (!current) return;
el.dataset.tip = current;
el.removeAttribute("title");
parked.current = el;
setTip({ text: current, rect: el.getBoundingClientRect() });
}, delay);
},
[restore]
);
useEffect(() => {
const target = (e: Event) => {
const node = e.target;
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
};
const onOver = (e: PointerEvent) => {
const el = target(e);
if (el) open(el, SHOW_DELAY_MS);
};
const onOut = (e: PointerEvent) => {
// Moving between children of the same control must not flicker.
const el = target(e);
const to = e.relatedTarget;
if (el && to instanceof Node && el.contains(to)) return;
if (el || parked.current) restore();
};
// Keyboard users get the same tooltip, immediately — a focus ring that
// reveals nothing is the whole reason icon-only buttons are hard to learn.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (el) open(el, 0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") restore();
};
document.addEventListener("pointerover", onOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("pointerdown", restore);
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", restore);
document.addEventListener("keydown", onKey);
// A bubble anchored to a rect is wrong the moment anything moves.
window.addEventListener("scroll", restore, true);
window.addEventListener("resize", restore);
return () => {
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("pointerdown", restore);
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", restore);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", restore, true);
window.removeEventListener("resize", restore);
restore();
};
}, [open, restore]);
// Placed after the first paint, when the bubble's real size is known —
// measuring it is the only way to flip and clamp without guessing.
useEffect(() => {
if (!tip || !bubble.current) return;
const { width, height } = bubble.current.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const centre = tip.rect.left + tip.rect.width / 2;
let left = centre - width / 2;
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
// Below by default; above only when below would run off the screen AND
// above actually fits, so a control at the very bottom still gets a bubble.
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
setPos({
left,
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
// The arrow points at the control even after the bubble was clamped.
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
above,
});
}, [tip]);
if (!tip) return null;
return createPortal(
<div
ref={bubble}
role="tooltip"
data-testid="global-tooltip"
style={{
position: "fixed",
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
opacity: pos ? 1 : 0,
transition: "opacity 120ms ease-out",
}}
className={TOOLTIP_SURFACE}
>
{tip.text}
{/* Two borders only: a fully bordered diamond draws its other two
edges straight across the bubble it is attached to. */}
<span
aria-hidden
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
pos?.above ? "border-b border-r" : "border-l border-t"
} border-border`}
/>
</div>,
document.body
);
}
export default GlobalTooltip;
+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,108 @@
/**
* @file Tooltip.test.tsx
* @description Pins the app-wide tooltip renderer: a control's native `title`
* becomes the designed bubble on hover and on keyboard focus, the native
* tooltip is suppressed only while the bubble is up and the attribute is put
* back afterwards (it is the control's accessible name), and `data-tip-off`
* opts out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
return render(
<>
<GlobalTooltip />
<button title="Refresh the working copy">refresh</button>
{extra}
</>
);
}
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
const settle = async () => {
await act(async () => {
vi.advanceTimersByTime(400);
});
};
describe("GlobalTooltip", () => {
it("shows the bubble after a delay and takes the native title while it is up", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
expect(screen.queryByTestId("global-tooltip")).toBeNull();
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
expect(button).not.toHaveAttribute("title");
});
it("hides and gives the title back on the way out", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
await settle();
unhover(button);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("opens immediately on keyboard focus and closes on Escape", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
fireEvent.focusIn(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
setup(
<button title="Raw value" data-tip-off>
opted out
</button>
);
const opted = screen.getByRole("button", { name: "opted out" });
hover(opted);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(opted).toHaveAttribute("title", "Raw value");
});
it("reads the title through a child element, and ignores an empty one", async () => {
setup(
<button title="">
<span data-testid="blank-child">blank</span>
</button>
);
hover(screen.getByTestId("blank-child"));
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
const button = screen.getByRole("button", { name: "refresh" });
button.innerHTML = "<span data-testid='deep'>refresh</span>";
hover(screen.getByTestId("deep"));
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
});
@@ -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-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-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`;
}
+367 -88
View File
@@ -24,8 +24,20 @@ 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";
/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only.
* Nothing is sent from it: an empty branch field means "let the server name
* it", which is how `LANE_BRANCH_PREFIX` keeps working. */
function slugPreview(title: string) {
return title
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -77,11 +89,16 @@ export function AddLaneModal({
onClose,
onAdded,
cwdSuggestions,
prefill,
}: {
open: boolean;
onClose: () => void;
/** Called with the newly created (possibly still-provisioning) lane. */
onAdded: (lane: Lane) => void;
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
* A branch here means the sibling worktree checks out THAT branch, which
* is the existing-branch mode — the reason that mode exists (issue #34). */
prefill?: { sourceRepo?: string; branch?: string } | null;
/** The same suggestion list the Run form already fetched (dashboard cwd,
* home, recently-used paths) — reused rather than fetched a second time. */
cwdSuggestions: CwdSuggestion[];
@@ -92,9 +109,17 @@ export function AddLaneModal({
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
// Two ways to start a worktree lane, and git's own rule decides which is
// possible: "new" forks a branch off `base`, "existing" checks out a branch
// that is already there — which git allows exactly once across all of a
// repo's worktrees, hence the busy markers below.
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
const [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 +129,7 @@ export function AddLaneModal({
profile: "scaffolded" | "skipped" | "failed";
agents: "ok" | "failed";
mcp: "ok" | "failed";
router: "ok" | "skipped" | "failed";
} | null>(null);
const reset = () => {
@@ -112,8 +138,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 +150,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 +202,7 @@ export function AddLaneModal({
const path = sourceRepo.trim();
if (!path) {
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(null);
return;
@@ -163,6 +213,7 @@ export function AddLaneModal({
const r = await api.lanes.branches(path);
if (lookedUpFor.current !== path) return; // a newer path superseded this one
setBranches(r.branches);
setWorktrees(r.worktrees || []);
setBase(r.current || r.branches[0] || "");
setBranchesError(null);
} catch {
@@ -170,6 +221,7 @@ export function AddLaneModal({
// Not yet a valid repo path (still being typed, or genuinely wrong) -
// quiet by design, the same way CwdAutocomplete never errors either.
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(t("addLaneNotARepo"));
}
@@ -177,6 +229,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 +245,26 @@ export function AddLaneModal({
const result = await api.lanes.ensure({
cwd: repo,
title: name || undefined,
pipeline,
tool,
});
// 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);
// 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 +274,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 +301,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 +316,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 +328,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 +369,33 @@ export function AddLaneModal({
onClose();
}, [onClose, browseOpen]);
// A summary on screen means the lane already exists and a step failed, so
// the confirm button has no second creation to run: it closes instead. That
// is the difference between a dialog that is finished and one that is stuck.
const done = !!setupResult;
const disabled =
!!setupResult ||
!sourceRepo.trim() ||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
!done &&
(!sourceRepo.trim() ||
(mode === "worktree" &&
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
/** branch name -> the worktree directory already holding it. git allows one
* checkout per branch, so these are the branches this form cannot use. */
const busyBranches = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
return (
<ConfirmModal
open={open}
title={t("addLane")}
confirmLabel={t("add")}
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={disabled}
onConfirm={submit}
onConfirm={done ? handleCancel : submit}
onCancel={handleCancel}
>
<div className="space-y-3">
@@ -356,6 +458,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 +498,215 @@ export function AddLaneModal({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
{t("addLanePipelineLabel")}
</label>
<select
id="add-lane-pipeline"
value={pipeline}
onChange={(e) => setPipeline(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
(p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
)
)}
</select>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
)}
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
project usually wants the lane to BE the repo, working on
whatever is checked out (`main`) — which is exactly Repo
mode, so this jumps there rather than inventing a second way
to do it. git cannot check `main` out in a second worktree
while the repo itself holds it, so a worktree lane could not
offer this even if it wanted to. */}
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// The placeholder is what the SERVER will name the branch
// if this is left empty, so the default is visible without
// the form having to duplicate LANE_BRANCH_PREFIX.
placeholder={
title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder")
}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
)}
</div>
)}
{/* The router is the one per-lane extra this form still asks for, so it
sits in the form itself — a disclosure around a single field only
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active =
option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
</div>
</div>
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
@@ -445,6 +719,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)) : [];
+411 -91
View File
@@ -1,29 +1,38 @@
/**
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
* @file One lane's card: branch/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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
import type { AiRouter } from "../../lib/api";
import type { Lane, LaneGitFacts, LaneGitFile, 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
* 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;
@@ -41,8 +50,11 @@ const LOCKS_REFRESH_MS = 30_000;
* polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders as a card without a git row.
*/
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
@@ -64,9 +76,9 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
alive = false;
clearInterval(timer);
};
}, [laneId]);
}, [laneId, nonce]);
return facts;
return [facts, () => setNonce((n) => n + 1)];
}
/**
@@ -134,38 +146,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 git/runtime pollers 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 +312,8 @@ export function since(sec: number | null): string {
}
/**
* Repo tooling (agents install / MCP sync / dev-sync check) for one lane,
* Repo tooling (agents install / MCP sync / dev-sync check, and the
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
* collapsed into a single dropdown. Lives outside LaneCard so it can be
* placed in the Workspace page header, next to "Add lane" — it polls its
* own runtime state independently of LaneCard's, same pattern as the
@@ -191,14 +322,14 @@ export function since(sec: number | null): string {
export function LaneToolsMenu({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
null
);
const [laneActionBusy, setLaneActionBusy] = useState<
"agents" | "mcp" | "sync-check" | "docs" | null
>(null);
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
const runtime = useLaneRuntime(lane.id, 0);
const runLaneAction = async (
which: "agents" | "mcp" | "sync-check",
which: "agents" | "mcp" | "sync-check" | "docs",
fn: () => Promise<string>
) => {
setLaneActionBusy(which);
@@ -218,6 +349,19 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
});
// A skill only exists inside a CLI session, so there is nothing server-side
// to call: this opens the lane's own terminal run with the slash command as
// its first message. Claude-only — codex has no slash commands.
const handleWriteDocs = () =>
runLaneAction("docs", async () => {
await api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: "/sg-all:sg-md-writing",
});
return t("actions.writeDocsResult");
});
const handleMcpSync = () =>
runLaneAction("mcp", async () => {
const result = await api.lanes.mcpSync(lane.id);
@@ -273,6 +417,22 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
>
{t("actions.agentsInstall")}
</button>
{lane.tool !== "codex" && (
<button
type="button"
role="menuitem"
data-testid="lane-write-docs"
title={t("actions.writeDocsHint")}
onClick={(e) => {
e.stopPropagation();
setToolsMenuOpen(false);
void handleWriteDocs();
}}
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
>
{t("actions.writeDocs")}
</button>
)}
<button
type="button"
role="menuitem"
@@ -313,11 +473,147 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
);
}
/** 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`, for the same reason this
* file's own header says the tools dropdown was moved out of the card: a lane
* card renders inside the Workspace's `max-h-[45vh] overflow-y-auto` box, and
* an absolutely positioned panel would be clipped by it for every card that is
* not at the very top. 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 default function LaneCard({
lane,
onAction,
childWorktrees,
onSelectLane,
onNewLaneFromRepo,
}: {
lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void;
@@ -327,16 +623,33 @@ export default function LaneCard({
childWorktrees?: Lane[];
/** Jumps the Workspace page's selection to another lane's card. */
onSelectLane?: (id: number) => void;
/** Opens the Add-lane flow prefilled with this lane's repo, for a sibling
* worktree on `branch`. Omitted where that flow is not reachable. */
onNewLaneFromRepo?: (repo: string, branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id);
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), []);
const [git, refreshGit] = 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
@@ -365,23 +678,6 @@ export default function LaneCard({
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"
>
{/* 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 && (
@@ -477,30 +773,72 @@ 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>
{/* One line, because every part of it is short and the panel is
narrow: branch, head, subject, and the uncommitted counts read as a
sentence rather than a four-row block. The cwd is NOT here — the
lane-detail header above this card already spells it out. */}
{git?.available && (
<div className="mb-2">
<div
data-testid="lane-git"
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
title={git.subject}
>
<span className="shrink-0">⑂ {git.branch}</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="truncate text-fg-muted">{git.subject}</span>
{(git.dirty > 0 || git.untracked > 0) && (
<div className="text-status-warning/80">
<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 text-status-warning/80 underline decoration-dotted underline-offset-2"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</div>
</button>
)}
</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}
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div>
</dl>
)}
{git?.available && (
<LaneGitBar
laneId={lane.id}
git={git}
onChanged={refreshGit}
/* A sibling worktree forks from the lane's SOURCE repo when it
has one (this lane is itself a worktree of it); an adopted lane
is its own repo. */
onNewLane={
onNewLaneFromRepo
? (branch) => onNewLaneFromRepo(lane.source_repo || lane.cwd, branch)
: undefined
}
/>
)}
{/* The lane's own recorded branch, shown only when git could not be
read — otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && (
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
⑂ {lane.branch}
</div>
)}
{childWorktrees && childWorktrees.length > 0 && (
<div
@@ -527,24 +865,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 —
@@ -0,0 +1,905 @@
/**
* @file The Changes panel: the lane's code beside its console, in the five
* shapes a review actually needs —
*
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
* `commit` (one commit, reached from history).
*
* Only the overview polls. Review, file, history and browse are a deliberate
* read of a frozen moment — re-fetching under the reader's scroll position is
* worse than a slightly stale view, and those are the expensive shapes.
*
* Rendering lives in LaneDiffView.tsx. Every write goes through
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
* before the server is told `confirm: true` — it is the one action in this
* panel git cannot undo.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
Columns2,
FileText,
FoldVertical,
Folder,
Minus,
Plus,
RefreshCw,
Rows3,
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import type {
LaneChangeFile,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneTreeEntry,
} from "../../lib/types";
/** How often the overview re-reads. Summary only — no patch text. */
const OVERVIEW_REFRESH_MS = 15_000;
/** Context steps the expand button walks through, then the whole file. */
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
type Mode = "overview" | "review" | "file" | "history" | "browse";
/** Colour per porcelain status letter, matching LaneCard's file panel. */
function statusClass(status: string, untracked: boolean) {
if (untracked) return "text-status-success";
const code = status.trim()[0];
if (code === "A") return "text-status-success";
if (code === "D") return "text-status-danger";
if (code === "R") return "text-accent";
return "text-status-warning";
}
/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file
* has no line delta at all, so it says `bin` rather than leaving the row's
* right edge blank, which reads as "changed by nothing". */
function Delta({
added,
deleted,
binary,
}: {
added: number | null;
deleted: number | null;
binary?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
if (binary)
return <span className="shrink-0 font-mono text-[10px] text-fg-muted">{t("changes.bin")}</span>;
return (
<span className="shrink-0 font-mono text-[10px]">
{added ? <span className="text-status-success">+{added}</span> : null}
{added && deleted ? " " : null}
{deleted ? <span className="text-status-danger">−{deleted}</span> : null}
</span>
);
}
/**
* One folder level of the browse tree, rendered in place and recursing into
* every expanded child — so opening a nested file no longer means navigating
* away from its siblings.
*/
function TreeNodes({
path,
depth,
children,
expanded,
onToggleDir,
onOpenFile,
}: {
path: string;
depth: number;
children: Record<string, LaneTreeEntry[]>;
expanded: Set<string>;
onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void;
}) {
const entries = children[path];
if (!entries) return null;
return (
<>
{entries.map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
className="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{e.dir ? (
expanded.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)
) : (
<span className="w-3 shrink-0" />
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-accent" />
) : (
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span className="min-w-0 flex-1 truncate">{e.name}</span>
</button>
{e.dir && expanded.has(e.path) && (
<ul>
<TreeNodes
path={e.path}
depth={depth + 1}
children={children}
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
/>
</ul>
)}
</li>
))}
</>
);
}
function IconButton({
label,
onClick,
disabled,
danger,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={`shrink-0 rounded p-0.5 disabled:opacity-40 ${
danger ? "text-fg-muted hover:text-status-danger" : "text-fg-muted hover:text-fg-primary"
}`}
>
{children}
</button>
);
}
export function LaneChangesPanel({ laneId }: { laneId: number }) {
const { t } = useTranslation(["lanes"]);
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
const [whole, setWhole] = useState(false);
const [data, setData] = useState<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [hits, setHits] = useState<string[] | null>(null);
const [text, setText] = useState<{ path: string; lines: string[]; truncated: boolean } | null>(
null
);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
// Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Where opening a file came FROM: its mode and how far the list was scrolled.
// Reviewing a change-set is a walk down a long list, and landing back at the
// top of it after every file is the whole cost of opening one.
const scroller = useRef<HTMLDivElement>(null);
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await api.lanes.changes(laneId, {
range,
...(range === "commit" && sha ? { sha } : {}),
...(mode === "file" && openFile ? { path: openFile, context } : {}),
...(mode === "review" ? { full: true } : {}),
});
setData(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [laneId, range, sha, mode, openFile, context]);
useEffect(() => {
if (mode === "history" || mode === "browse") return;
void load();
// Only the cheap shape polls; see the file header.
if (mode !== "overview") return;
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
return () => window.clearInterval(timer);
}, [load, mode]);
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
useEffect(() => {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.then((r) => setCommits(r.commits))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId]);
useEffect(() => {
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
void api.lanes
.gitTree(laneId, "")
.then((r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` is the cache this fills — reading it as a dep would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, laneId, query]);
// A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.then((r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries })))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const [treeBusy, setTreeBusy] = useState(false);
// Fetches whatever level isn't cached yet, depth-first, so "expand all"
// works even on a tree nothing has been opened in.
const expandAllDirs = async () => {
setTreeBusy(true);
const cache = { ...treeChildren };
const toExpand = new Set(expandedDirs);
const walk = async (path: string) => {
let entries = cache[path];
if (!entries) {
entries = (await api.lanes.gitTree(laneId, path)).entries;
cache[path] = entries;
}
const dirs = entries.filter((e) => e.dir);
for (const d of dirs) toExpand.add(d.path);
await Promise.all(dirs.map((d) => walk(d.path)));
};
try {
await walk("");
setTreeChildren(cache);
setExpandedDirs(toExpand);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setTreeBusy(false);
}
};
useEffect(() => {
const q = query.trim();
if (mode !== "browse" || !q) {
setHits(null);
return;
}
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(laneId, q)
.then((r) => setHits(r.paths))
.catch(() => setHits([]));
}, 150);
return () => window.clearTimeout(timer);
}, [mode, laneId, query]);
// The whole-file read is a different endpoint from the diff, so it is its
// own fetch — and only while the viewer is actually showing it.
useEffect(() => {
if (mode !== "file" || !whole || !openFile) return;
void api.lanes
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.then((r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]);
// A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one.
useEffect(() => {
setMode("overview");
setOpenFile(null);
setSha(null);
setRange("work");
setTreeChildren({});
setExpandedDirs(new Set());
setCommits(null);
setCollapsed(new Set());
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
// Restoring has to wait for the list to be TALL enough again: leaving a file
// re-fetches the mode it came from, so the first render back is an empty
// box that would swallow the scroll.
useLayoutEffect(() => {
const el = scroller.current;
const want = pendingScroll.current;
if (mode === "file" || want == null || !el) return;
if (el.scrollHeight - el.clientHeight < want) return;
el.scrollTop = want;
pendingScroll.current = null;
}, [mode, data]);
const show = (path: string) => {
// Opening a file FROM a file (the review list is gone by then) must not
// overwrite the list position that is still waiting to be restored.
if (mode !== "file") {
returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 };
}
setOpenFile(path);
setContext(CONTEXT_STEPS[0]);
setWhole(false);
setText(null);
setMode("file");
};
const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => {
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(laneId, action, { paths: [path], ...(confirm ? { confirm } : {}) });
setConfirmDiscard(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const commitStaged = async () => {
setBusy(true);
setError(null);
try {
// `only: "staged"` — the whole point of staging file by file is that the
// commit takes the index as it stands, with no `add -A` sweeping the
// rest of the tree in behind it.
await api.lanes.gitAction(laneId, "commit", { message: message.trim(), only: "staged" });
setMessage("");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
const Row = ({ f }: { f: LaneChangeFile }) => (
<li className="group flex items-baseline gap-1">
<button
type="button"
onClick={() => show(f.path)}
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span
className={`w-3 shrink-0 font-mono text-[11px] font-semibold ${statusClass(
f.status,
f.untracked
)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
title={f.path}
>
{f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" && (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => void act("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => void act("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={confirmDiscard === f.path ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy}
danger
onClick={() => {
// Two clicks, always: the first arms the row, the second is the
// only one that reaches the server (which itself refuses the
// call without `confirm: true`).
if (confirmDiscard === f.path) void act("discard", f.path, true);
else setConfirmDiscard(f.path);
}}
>
<Trash2
className={`h-3 w-3 ${confirmDiscard === f.path ? "text-status-danger" : ""}`}
/>
</IconButton>
</span>
)}
</li>
);
return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
{/* Two rows on purpose: WHERE you are (mode, and the refresh that always
applies) on top, HOW you are looking at it (range, layout, context,
whole file) underneath. One row wrapped mid-group as soon as the
panel was narrow, which is every panel beside a console. */}
<div className="mb-2 space-y-1.5">
<div className="flex items-center gap-1.5">
{mode === "file" ? (
<button
type="button"
onClick={() => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
}}
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{(["browse", "overview", "review", "history"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => setMode(m)}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m
? "bg-accent text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`changes.mode.${m}`)}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => void load()}
aria-label={t("changes.refresh")}
title={t("changes.refresh")}
className="ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{mode !== "history" && mode !== "browse" && (
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["work", "branch"] as const).map((r) => (
<button
key={r}
type="button"
aria-pressed={range === r}
onClick={() => {
setRange(r);
setSha(null);
}}
title={t(`changes.range.${r}Hint`)}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
range === r
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`changes.range.${r}`)}
</button>
))}
{range === "commit" && sha && (
<span
data-testid="lane-changes-commit-chip"
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
>
{sha.slice(0, 7)}
</span>
)}
</div>
{(mode === "file" || mode === "review") && (
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
{mode === "review" && (
<IconButton
label={allCollapsed ? t("changes.expandAll") : t("changes.collapseAll")}
disabled={files.length === 0}
onClick={() =>
setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path)))
}
>
{allCollapsed ? (
<ChevronsUpDown className="h-3.5 w-3.5" />
) : (
<ChevronsDownUp className="h-3.5 w-3.5" />
)}
</IconButton>
)}
<IconButton
label={t(`changes.layout.${layout === "unified" ? "split" : "unified"}`)}
onClick={() => setLayout(layout === "unified" ? "split" : "unified")}
>
{layout === "unified" ? (
<Columns2 className="h-3.5 w-3.5" />
) : (
<Rows3 className="h-3.5 w-3.5" />
)}
</IconButton>
{mode === "file" && (
<>
{/* Context walks BOTH ways: an expanded diff that cannot
be collapsed again leaves the reader re-opening the
file to get the tight view back. */}
<IconButton
label={t("changes.collapseContext")}
disabled={whole || context <= CONTEXT_STEPS[0]}
onClick={() =>
setContext(
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
CONTEXT_STEPS[0]
)
}
>
<FoldVertical className="h-3.5 w-3.5" />
</IconButton>
<IconButton
label={t("changes.expandContext")}
disabled={whole || context >= LAST_CONTEXT}
onClick={() => setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)}
>
<UnfoldVertical className="h-3.5 w-3.5" />
</IconButton>
</>
)}
</div>
)}
{mode === "file" && (
<button
type="button"
aria-pressed={whole}
onClick={() => setWhole(!whole)}
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
}`}
>
{t("changes.wholeFile")}
</button>
)}
</div>
)}
</div>
{error && (
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
{error}
</p>
)}
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
bar sits at the bottom of the visible box instead of at the bottom of
a 2000-line container nobody has scrolled to yet. Every other mode is
a list, and a list scrolls here. */}
<div
ref={scroller}
data-testid="lane-changes-body"
className={`flex min-h-0 flex-1 flex-col ${
mode === "file" ? "overflow-hidden" : "overflow-y-auto"
}`}
>
{mode === "history" ? (
<ul data-testid="lane-changes-history" className="space-y-0.5">
{(commits ?? []).map((c) => (
<li key={c.sha}>
<button
type="button"
onClick={() => {
setSha(c.sha);
setRange("commit");
setMode("review");
}}
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</button>
</li>
))}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
</ul>
) : mode === "browse" ? (
<div data-testid="lane-changes-browse">
<div className="mb-2 flex items-center gap-1.5">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("changes.quickOpen")}
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
{!hits && (
<IconButton
label={
expandedDirs.size > 0
? t("changes.collapseAllFolders")
: t("changes.expandAllFolders")
}
disabled={treeBusy}
onClick={() =>
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
}
>
{expandedDirs.size > 0 ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</IconButton>
)}
</div>
{hits ? (
<ul className="space-y-0.5">
{hits.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => {
show(p);
setWhole(true);
}}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
</button>
</li>
))}
{hits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
)}
</ul>
) : (
<ul className="space-y-0.5">
<TreeNodes
path=""
depth={0}
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={(p) => {
show(p);
setWhole(true);
}}
/>
</ul>
)}
</div>
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
) : files.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
) : mode === "overview" ? (
<>
<ul data-testid="lane-changes-overview" className="space-y-0.5">
{files.map((f) => (
<Row key={f.path} f={f} />
))}
</ul>
{stagedCount > 0 && (
<div
data-testid="lane-changes-commit"
className="mt-2 flex items-center gap-1.5 border-t border-border-light pt-2"
>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
}}
placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || !message.trim()}
onClick={() => void commitStaged()}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.commitStaged", { count: stagedCount })}
</button>
</div>
)}
</>
) : mode === "file" ? (
current || (whole && text) ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1 flex items-baseline gap-1.5">
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
{openFile}
</span>
{current && (
<Delta added={current.added} deleted={current.deleted} binary={current.binary} />
)}
</div>
{whole ? (
text ? (
<LaneFileView
path={text.path}
lines={text.lines}
truncated={text.truncated}
fill
/>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
)
) : current ? (
<LaneDiffView file={current} layout={layout} fill />
) : null}
</div>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.fileGone")}</p>
)
) : (
<div data-testid="lane-changes-review" className="space-y-3">
{files.map((f) => {
const shut = collapsed.has(f.path);
return (
<div key={f.path} className="rounded border border-border-light">
{/* The whole header folds the file; only the PATH opens it.
A header that opened the file on any click made folding a
target-practice exercise against one chevron. */}
<div
role="button"
tabIndex={0}
onClick={() => toggleCollapsed(f.path)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCollapsed(f.path);
}
}}
aria-expanded={!shut}
aria-label={
shut
? t("changes.expandFile", { path: f.path })
: t("changes.collapseFile", { path: f.path })
}
className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
shut ? "" : "border-b border-border-light"
}`}
>
<ChevronRight
className={`h-3 w-3 shrink-0 self-center text-fg-muted transition-transform ${
shut ? "" : "rotate-90"
}`}
/>
<span
className={`shrink-0 font-mono text-[11px] font-semibold ${statusClass(f.status, f.untracked)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
show(f.path);
}}
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-fg-primary hover:underline"
title={t("changes.openFile", { path: f.path })}
>
{f.path}
</button>
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</div>
{!shut && <LaneDiffView file={f} layout={layout} />}
</div>
);
})}
</div>
)}
{data?.available && data.truncated && mode !== "history" && mode !== "browse" && (
<p className="mt-2 text-[10px] text-status-warning">{t("changes.truncated")}</p>
)}
</div>
</div>
);
}
export default LaneChangesPanel;
@@ -0,0 +1,317 @@
/**
* @file The diff renderer behind the Changes panel (issue #35): one file's
* hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted,
* with the changed span inside a modified line picked out.
*
* Three deliberate choices:
* - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation
* view already ships. No new dependency for a panel that renders a few
* hundred lines.
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
* right for the case that matters (one edited span on a line), degrades to
* "the whole line changed" otherwise, and costs nothing.
* - Line pairing for split view happens here, on the rows the server already
* numbered. The server stays the single source of what changed; this file
* only decides how it looks.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
export type DiffLayout = "unified" | "split";
/**
* How tall a diff box may be. `fill` (the single-file view) takes the height
* its parent gives it; otherwise — the review list, which stacks many files —
* each file is capped so its own horizontal scrollbar stays on screen.
*/
function boxHeight(fill: boolean) {
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
}
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) {
const ext = filePath.split(".").pop() || "";
return canonicalLang(ext === filePath ? "plain" : ext);
}
/** Tokenized line text. `emph` marks the span that actually changed. */
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) {
const [from, to] = emph;
return (
<>
<Code text={text.slice(0, from)} lang={lang} />
<span className="rounded-sm bg-white/15">
<Code text={text.slice(from, to)} lang={lang} />
</span>
<Code text={text.slice(to)} lang={lang} />
</>
);
}
return (
<>
{highlight(text, lang).map((tok, i) => (
<span key={i} className={tokenClass(tok.type)}>
{tok.text}
</span>
))}
</>
);
}
/**
* The changed span of a del/add pair, as `[start, end]` offsets into each
* side, or null when the lines share nothing worth pointing at.
*/
function emphasis(
del: string,
add: string
): { del: [number, number]; add: [number, number] } | null {
if (del === add) return null;
let start = 0;
const max = Math.min(del.length, add.length);
while (start < max && del[start] === add[start]) start += 1;
let end = 0;
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
// Nothing in common at either edge: the whole line is the change, and
// highlighting all of it adds noise on top of the row tint.
if (start === 0 && end === 0) return null;
return {
del: [start, del.length - end],
add: [start, add.length - end],
};
}
/** Pair each run of removed lines with the run of added lines following it. */
function pairs(
lines: LaneDiffLine[]
): Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> {
const out: Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> = [];
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
out.push({ left: line, right: line });
i += 1;
continue;
}
const dels: LaneDiffLine[] = [];
const adds: LaneDiffLine[] = [];
while (i < lines.length && lines[i]!.type === "del") dels.push(lines[i++]!);
while (i < lines.length && lines[i]!.type === "add") adds.push(lines[i++]!);
for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) {
out.push({ left: dels[k] ?? null, right: adds[k] ?? null });
}
}
return out;
}
function Gutter({ n }: { n: number | null }) {
return (
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
);
}
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. */
function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
}
function rowTint(type: LaneDiffLine["type"] | null) {
if (type === "add") return "bg-status-success/10";
if (type === "del") return "bg-status-danger/10";
return "";
}
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
const at = hunk.header.split("@@")[1];
return (
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</div>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const emph = new Map<number, [number, number]>();
for (const { left, right } of pairs(hunk.lines)) {
if (!left || !right || left.type !== "del" || right.type !== "add") continue;
const e = emphasis(left.text, right.text);
if (!e) continue;
emph.set(hunk.lines.indexOf(left), e.del);
emph.set(hunk.lines.indexOf(right), e.add);
}
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<Row key={i} tint={rowTint(line.type)}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className="select-none text-fg-muted">
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph.get(i)} />
</span>
</Row>
))}
</div>
);
}
/** One side of the split view: a hunk header, then that side's rows. */
function SplitColumn({
hunks,
lang,
side,
}: {
hunks: LaneDiffHunk[];
lang: string;
side: "left" | "right";
}) {
return (
<>
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} />
{pairs(hunk.lines).map(({ left, right }, i) => {
const e =
left && right && left.type === "del" && right.type === "add"
? emphasis(left.text, right.text)
: null;
const line = side === "left" ? left : right;
return (
<Row key={i} tint={rowTint(line?.type ?? null)}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code text={line.text} lang={lang} emph={side === "left" ? e?.del : e?.add} />
) : null}
</span>
</Row>
);
})}
</div>
))}
</>
);
}
/** Both sides, each scrolling horizontally on its own but kept in step. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
// Mirroring fires the other pane's own scroll handler, which mirrors back —
// the equality check is what ends that, so no re-entry flag is needed.
const sync = useCallback((from: "left" | "right") => {
const src = from === "left" ? left.current : right.current;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst || dst.scrollLeft === src.scrollLeft) return;
dst.scrollLeft = src.scrollLeft;
}, []);
return (
<div className={`flex ${boxHeight(fill)}`}>
<div
ref={left}
data-testid="lane-diff-split-left"
onScroll={() => sync("left")}
className="w-1/2 min-w-0 overflow-auto"
>
<SplitColumn hunks={hunks} lang={lang} side="left" />
</div>
<div
ref={right}
data-testid="lane-diff-split-right"
onScroll={() => sync("right")}
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
>
<SplitColumn hunks={hunks} lang={lang} side="right" />
</div>
</div>
);
}
/** One file's diff, in the chosen layout. */
export function LaneDiffView({
file,
layout,
fill = false,
}: {
file: LaneChangeFile;
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
if (!file.hunks || file.hunks.length === 0)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
return (
<div
data-testid="lane-diff-view"
data-layout={layout}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
))
)}
{file.truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
);
}
/** A whole file's text (no diff), for the viewer's "read the file" mode. */
export function LaneFileView({
path,
lines,
truncated,
fill = false,
}: {
path: string;
lines: string[];
truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */
fill?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
return (
<div
data-testid="lane-file-view"
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
>
{lines.map((text, i) => (
<Row key={i} tint="">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
))}
{truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
);
}
export default LaneDiffView;
+352
View File
@@ -0,0 +1,352 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, commit (everything, or only what the Changes panel has
* staged), switch branch, and create/delete a branch. Per-file staging and
* discard live in the Changes panel, beside the file they act on.
*
* It renders beside the read-only git line in LaneCard rather than inside the
* ⋯ menu: that menu holds the rare and dangerous (`reset worktree`), and these
* are the opposite — the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
Check,
GitBranch,
Plus,
RefreshCw,
Trash2,
Upload,
X,
} from "lucide-react";
import { api } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action = "fetch" | "pull" | "push" | "commit" | "switch" | "branch-create" | "branch-delete";
function Btn({
label,
icon,
busy,
disabled,
title,
onClick,
}: {
label: string;
icon?: React.ReactNode;
busy?: boolean;
disabled?: boolean;
title?: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || busy}
title={title}
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{icon}
{label}
</button>
);
}
export function LaneGitBar({
laneId,
git,
onChanged,
onNewLane,
}: {
laneId: number;
git: LaneGitFacts;
/** Called after any action that could have moved the working copy. */
onChanged: () => void;
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
* a branch this bar just created. Omitted where no such flow is reachable. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState<Action | null>(null);
const [error, setError] = useState<string | null>(null);
const [committing, setCommitting] = useState(false);
const [message, setMessage] = useState("");
const [switching, setSwitching] = useState(false);
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
const run = async (
action: Action,
body?: { message?: string; only?: "staged"; branch?: string }
) => {
setBusy(action);
setError(null);
try {
await api.lanes.gitAction(laneId, action, body || {});
setCommitting(false);
setMessage("");
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "switch") setSwitching(false);
else if (action.startsWith("branch-")) {
setNewBranch("");
setPicked("");
setBranches(null);
await refreshBranches();
}
onChanged();
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
setBranches(r.branches);
setWorktrees(r.worktrees || []);
} catch {
setBranches([]);
}
};
const openSwitch = async () => {
setSwitching((v) => !v);
setError(null);
if (branches) return;
await refreshBranches();
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
return (
<div data-testid="lane-git-bar" className="mb-2 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{git.upstream ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
className="flex items-center gap-1 font-mono text-[11px] text-fg-muted"
>
{(git.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning">
<ArrowUp className="h-3 w-3" />
{git.ahead}
</span>
)}
{(git.behind ?? 0) > 0 && (
<span className="flex items-center text-accent">
<ArrowDown className="h-3 w-3" />
{git.behind}
</span>
)}
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
</span>
) : (
git.hasRemote && (
<span className="font-mono text-[11px] text-fg-muted">{t("gitBar.noUpstream")}</span>
)
)}
<Btn
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3 w-3" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3 w-3" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
label={t("gitBar.push")}
icon={<Upload className="h-3 w-3" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pushHint")}
onClick={() => run("push")}
/>
<Btn
label={t("gitBar.commit")}
icon={<Check className="h-3 w-3" />}
disabled={dirty === 0 || !!busy}
title={dirty === 0 ? t("gitBar.commitNothing") : t("gitBar.commitHint")}
onClick={() => {
setCommitting((v) => !v);
setError(null);
}}
/>
<Btn
label={t("gitBar.switch")}
icon={<GitBranch className="h-3 w-3" />}
disabled={!!busy}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={openSwitch}
/>
</div>
{committing && (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim()) void run("commit", { message });
if (e.key === "Escape") setCommitting(false);
}}
placeholder={t("gitBar.commitPlaceholder")}
aria-label={t("gitBar.commitPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.commitAll", { count: dirty })}
busy={busy === "commit"}
disabled={!message.trim()}
onClick={() => void run("commit", { message })}
/>
{/* The Changes panel's per-file staging only means something if a
commit can take the index as it stands. The server answers
ENOCHANGES when nothing is staged, and that text lands below. */}
<Btn
label={t("gitBar.commitStaged")}
title={t("gitBar.commitStagedHint")}
busy={busy === "commit"}
disabled={!message.trim()}
onClick={() => void run("commit", { message, only: "staged" })}
/>
<button
type="button"
aria-label={t("gitBar.cancel")}
onClick={() => setCommitting(false)}
className="text-fg-muted hover:text-fg-primary"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
onChange={(e) => setPicked(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
const held = heldBy.get(b);
return (
<option key={b} value={b}>
{b}
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
</option>
);
})}
</select>
<Btn
label={t("gitBar.switchApply")}
busy={busy === "switch"}
// A branch git already has checked out somewhere else cannot be
// switched to — git allows one worktree per branch.
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
/>
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
title={t("gitBar.newLaneHint")}
disabled={!picked}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
/>
)}
<Btn
label={t("gitBar.branchDelete")}
icon={<Trash2 className="h-3 w-3" />}
busy={busy === "branch-delete"}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
title={t("gitBar.branchDeleteHint")}
onClick={() => void run("branch-delete", { branch: picked })}
/>
{dirty > 0 && (
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
)}
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<input
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
}}
placeholder={t("gitBar.branchNewPlaceholder")}
aria-label={t("gitBar.branchNewPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.branchCreate")}
icon={<Plus className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// Creating does not switch: the point is usually a sibling lane,
// and moving this pane's HEAD under a working agent is not.
title={t("gitBar.branchCreateHint")}
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
/>
</div>
)}
{error && (
<pre
role="alert"
data-testid="lane-git-bar-error"
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
>
{error}
</pre>
)}
</div>
);
}
export default LaneGitBar;
+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,148 @@ 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");
// The placeholder previews the name the server will pick.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"feat/auto-named"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" })
)
);
});
});
describe("AddLaneModal — worktree mode (default)", () => {
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
// The branch NAME is no longer part of this list: an empty one means "let
// the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
@@ -143,6 +272,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main", "feat/other"],
current: "main",
worktrees: [],
});
renderModal();
const user = userEvent.setup();
@@ -175,7 +305,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn();
const onClose = vi.fn();
@@ -191,20 +325,24 @@ describe("AddLaneModal — worktree mode (default)", () => {
title: "New feature",
base: "main",
branch: "feat/new-feature",
pipeline: "default",
tool: "claude",
});
});
expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" })
);
// The modal stays open showing the setup summary until dismissed - it
// does not close itself just because the lane was added.
expect(onClose).not.toHaveBeenCalled();
// Setup came back clean, so there is nothing to report and the dialog is
// done. Leaving it up read as "still working" — its confirm button is dead
// once the lane exists — which is how it looked stuck.
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
const onClose = vi.fn();
renderModal({ onClose });
@@ -223,7 +361,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
});
@@ -242,7 +384,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("still calls onAdded even when every setup call fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
});
@@ -261,11 +407,16 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("shows the setup summary and lets the user dismiss it manually", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
@@ -273,17 +424,22 @@ describe("AddLaneModal — worktree mode (default)", () => {
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
// A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
if (!dismissButton) throw new Error("Cancel button not found");
await user.click(dismissButton);
// And then the primary button must close, not sit there disabled: the lane
// already exists, so "Add lane" has nothing left to do.
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
});
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
});
@@ -346,7 +502,6 @@ describe("AddLaneModal — repo mode (adopt)", () => {
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/Users/tester/projects/repo",
title: "main repo",
pipeline: "default",
tool: "claude",
})
);
@@ -398,47 +553,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 +739,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, fireEvent, 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,14 +159,12 @@ 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();
});
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%");
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
expect(screen.queryByText("48%")).toBeNull();
});
it("surfaces a needs-you message", () => {
@@ -204,6 +211,17 @@ describe("LaneCard git block", () => {
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 () => {
@@ -219,6 +237,57 @@ describe("LaneCard git block", () => {
expect(screen.queryByText(/stale\/recorded/)).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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
@@ -340,40 +409,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 +468,29 @@ describe("LaneToolsMenu", () => {
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
});
it("clicking Create/update readme opens the lane's terminal on the docs skill", async () => {
// Moved here from the Add-lane modal: a skill only runs inside a CLI
// session, and writing the docs is worth re-running long after creation.
vi.mocked(api.run.start).mockResolvedValue({} as never);
render(<LaneToolsMenu lane={makeLane({ cwd: "/work/demo" })} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-write-docs"));
await waitFor(() =>
expect(api.run.start).toHaveBeenCalledWith({
laneId: 1,
cwd: "/work/demo",
initialPrompt: "/sg-all:sg-md-writing",
})
);
});
it("hides Create/update readme on a codex lane, which has no slash commands", async () => {
render(<LaneToolsMenu lane={makeLane({ tool: "codex" })} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument();
expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument();
});
it("renders nothing when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
@@ -440,3 +498,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();
});
});
@@ -0,0 +1,540 @@
/**
* @file LaneChangesPanel.test.tsx
* @description Pins the Changes panel's five modes and its three ranges: the
* overview lists files and nothing else, clicking one opens that file's diff
* (and asks the server for THAT path), Review asks for every file's hunks at
* once, the range toggle changes which comparison is requested, History opens
* one commit, and Files quick-opens a whole file. Also pins the per-file git
* writes — including discard needing two clicks before the server hears
* `confirm` — and the degrade for a cwd git cannot read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
changes: vi.fn(),
gitAction: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
},
},
}));
const HUNK = {
header: "@@ -1,2 +1,3 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
{ type: "del" as const, oldLine: 2, newLine: null, text: "old line" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "new line" },
],
};
function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}): LaneChanges {
return {
available: true,
range: "work",
base: "development",
baseRef: "origin/development",
sha: null,
context: 3,
truncated: false,
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 12,
deleted: 3,
binary: false,
},
{ path: "notes.txt", status: "??", untracked: true, added: 4, deleted: 0, binary: false },
{
path: "docs/API.md",
status: "M ",
untracked: false,
staged: true,
added: 2,
deleted: 1,
binary: false,
},
],
...over,
};
}
beforeEach(() => {
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
vi.mocked(api.lanes.gitAction).mockReset();
vi.mocked(api.lanes.gitAction).mockResolvedValue({ action: "stage", ok: true, output: "" });
vi.mocked(api.lanes.gitLog).mockResolvedValue({
available: true,
commits: [
{
sha: "a".repeat(40),
short: "aaaaaaa",
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
refs: ["HEAD -> feat/thing"],
},
],
});
vi.mocked(api.lanes.gitLs).mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "src", path: "src", dir: true }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
});
});
describe("LaneChangesPanel", () => {
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.queryByText(/new line/)).toBeNull();
});
it("opens one file's diff when its row is clicked, asking for that path only", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 12,
deleted: 3,
binary: false,
hunks: [HUNK],
},
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
// The highlighter splits a line into one span per token, so assert on the
// rendered text of the whole view rather than on a single node.
const view = await screen.findByTestId("lane-diff-view");
expect(view).toHaveTextContent("new line");
// Both gutters are rendered, so a reviewer can cite a line number.
expect(view).toHaveTextContent("keep");
});
it("asks for every file's hunks in review mode", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", full: true })
);
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
});
it("switches the comparison when the range toggle is used", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "vs base" }));
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
});
it("says so, quietly, when the lane's directory is not a git repo", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<LaneChangesPanel laneId={7} />);
expect(
await screen.findByText("This lane's directory is not a readable git repository.")
).toBeInTheDocument();
});
it("stages one file and asks the server for that path alone", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// Both rows offer it (one modified, one untracked); the first is the
// modified file the assertion below names.
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "Click again to discard — this cannot be undone" })
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js"],
confirm: true,
});
});
it("opens a commit from history as a commit-range review", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
await user.click(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "commit",
sha: "a".repeat(40),
full: true,
})
);
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
});
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
path === "src"
? {
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
truncated: false,
}
: {
available: true,
path: "",
entries: [{ name: "src", path: "src", dir: true }],
truncated: false,
}
);
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.click(await screen.findByText("src"));
expect(await screen.findByText("main.ts")).toBeInTheDocument();
// The root's "src" row is still there — expanding did not replace the list.
expect(screen.getByText("src")).toBeInTheDocument();
});
it("expands every folder at once, fetching whatever level wasn't cached yet", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => {
if (path === "src")
return {
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
truncated: false,
};
return {
available: true,
path: "",
entries: [{ name: "src", path: "src", dir: true }],
truncated: false,
};
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
expect(await screen.findByText("main.ts")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Collapse all folders" }));
expect(screen.queryByText("main.ts")).not.toBeInTheDocument();
expect(screen.getByText("src")).toBeInTheDocument();
});
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await waitFor(() => expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "work"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
});
it("switches the diff to side-by-side", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 12,
deleted: 3,
binary: false,
hunks: [HUNK],
},
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
expect(screen.getByTestId("lane-diff-view")).toHaveAttribute("data-layout", "split");
});
it("expands and then collapses the context again", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an
// expanded diff that cannot be collapsed sends the reader back out of the
// file to get the tight view.
expect(screen.getByRole("button", { name: "Show fewer surrounding lines" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 12,
})
);
await user.click(screen.getByRole("button", { name: "Show fewer surrounding lines" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("asks for more context when the diff is expanded", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 12,
})
);
});
it("commits the index from the overview, never the whole tree", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// The box exists only because a file is staged — with a clean index there
// is nothing this button could mean.
const box = await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
expect(box).toBeInTheDocument();
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: the staged half",
only: "staged",
});
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("puts Files first in the mode row", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const modes = ["Files", "Overview", "Review", "History"].map((name) =>
screen.getByRole("button", { name })
);
// Source order is the reading order here, so comparing positions pins it.
for (let i = 1; i < modes.length; i += 1) {
expect(
modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
}
});
it("folds one file shut in review mode, and all of them at once", async () => {
const user = userEvent.setup();
const withHunks = changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 1,
deleted: 1,
binary: false,
hunks: [HUNK],
},
{
path: "notes.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
hunks: [HUNK],
},
],
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(withHunks);
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(1);
// The header stays, so a folded file is still a row you can reopen.
expect(
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Collapse every file" }));
expect(screen.queryAllByTestId("lane-diff-view")).toHaveLength(0);
await user.click(screen.getByRole("button", { name: "Expand every file" }));
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
});
it("opens a file only from its path, and folds it from anywhere else", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
// Anywhere on the header but the path: fold, do not navigate.
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
expect(screen.getByTestId("lane-changes-review")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("goes back to the list it came from, at the offset it was left at", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
// jsdom lays nothing out, so the scrollable box is described by hand —
// the restore deliberately waits until the list is tall enough again.
const body = screen.getByTestId("lane-changes-body");
Object.defineProperty(body, "scrollHeight", { value: 2000, configurable: true });
Object.defineProperty(body, "clientHeight", { value: 400, configurable: true });
body.scrollTop = 640;
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
await screen.findByRole("button", { name: /All files/ });
await user.click(screen.getByRole("button", { name: /All files/ }));
// Back to REVIEW (where the file was opened from), not to the overview.
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
});
});
@@ -0,0 +1,74 @@
/**
* @file LaneDiffView.test.tsx
* @description Pins the side-by-side renderer: two independently scrolling
* columns (a long line must be reachable, not painted over its neighbour)
* whose horizontal scroll is mirrored, so the two halves of a line stay
* opposite each other.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { LaneDiffView } from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 1,
deleted: 1,
binary: false,
hunks: [
{
header: "@@ -1,2 +1,2 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
],
},
],
};
describe("LaneDiffView — side by side", () => {
it("gives each column its own scroller", () => {
render(<LaneDiffView file={FILE} layout="split" />);
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
});
it("bounds its own height so the horizontal bar stays on screen", () => {
// A stacked review list caps each file; the single-file view takes the
// height its parent gives it. Either way the diff scrolls itself rather
// than growing a 2000-line container whose scrollbar is off the bottom.
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
it("mirrors one column's horizontal scroll onto the other", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
left.scrollLeft = 320;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(320);
right.scrollLeft = 0;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(0);
});
it("keeps the unified layout on one scroller", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
});
});
@@ -0,0 +1,175 @@
/**
* @file LaneGitBar.test.tsx
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press, that commit sends the typed
* message, that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
return {
available: true,
branch: "feat/thing",
head: "9b3e74a",
subject: "wip",
dirty: 2,
untracked: 1,
files: [],
hasRemote: true,
upstream: "origin/feat/thing",
ahead: 2,
behind: 1,
...over,
};
}
beforeEach(() => {
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
action: "fetch",
ok: true,
output: "",
});
vi.mocked(api.lanes.gitBranches)
.mockReset()
.mockResolvedValue({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [],
});
});
describe("LaneGitBar", () => {
it("shows both sides of the upstream comparison", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
const tracking = screen.getByTestId("lane-git-tracking");
expect(tracking).toHaveTextContent("2");
expect(tracking).toHaveTextContent("1");
});
it("disables the network actions when the repo has no remote", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ hasRemote: false, upstream: null })}
onChanged={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
// Local actions stay available — a repo with no remote still commits.
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
});
it("commits with the typed message and reports the change upward", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
);
expect(onChanged).toHaveBeenCalled();
});
it("offers no commit when nothing is uncommitted", () => {
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
// Picking is harmless; applying is what a dirty tree blocks.
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("switches branch on a clean tree", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await user.selectOptions(select, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
);
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
render(
<LaneGitBar
laneId={3}
git={facts({ dirty: 0, untracked: 0 })}
onChanged={vi.fn()}
onNewLane={onNewLane}
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "feat/sibling",
})
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "New lane here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
it("commits only the index when asked", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText(/Commit message/), "fix: staged only");
await user.click(screen.getByRole("button", { name: "Commit staged" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
message: "fix: staged only",
only: "staged",
})
);
});
it("prints git's own rejection text when an action fails", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
});
});
@@ -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);
});
});
+92 -47
View File
@@ -15,6 +15,15 @@
* swaps the whole pane over to the new lane — its cwd, its tool, its history,
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* A lane's pane is one tmux session, so starting or resuming into a lane
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
* that and attach to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
* reachable and the user did not ask to kill it. `killRun` is the other half:
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
* DOES want that previous session gone so a fresh start/resume can proceed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -75,7 +84,7 @@ export function LaneConsolePane({
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("auto");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
@@ -129,7 +138,6 @@ export function LaneConsolePane({
useEffect(() => {
const { activeRuns: runs } = latest.current;
setPrompt("");
lastPrefill.current = "";
setResumeSession(null);
setError(null);
setBusy(null);
@@ -138,34 +146,10 @@ export function LaneConsolePane({
refreshList();
}, [laneId, refreshList]);
// Pre-fill the prompt with the slash command the lane's pipeline template
// names as its driver, so picking the 16-node pipeline starts the 16-step
// skill and the 8-node one the 8-step skill. Only overwrites a prompt the
// user hasn't touched (still exactly the previous prefill), so switching
// templates never eats a typed requirement. Must be declared AFTER the
// lane-switch effect: on a lane change that one clears both first.
const lastPrefill = useRef("");
// Claude gets a slash command it can execute (`pipeline_skill`), which itself
// declares stage at every step. Codex has no slash commands — the same
// string would read as literal prose — so it gets the equivalent priming a
// different way: a plain-text checklist of this lane's own pipeline node ids,
// read directly off `pipeline_nodes` so it always matches the template in
// use. Without SOME opening nudge a codex lane's declared stage depends
// entirely on AGENTS.md being read on its own, which is easy to miss.
const promptPrefill =
laneTool === "codex"
? currentLane?.pipeline_nodes?.length
? `Declare progress as you go: run \`agenthub stage <stage> --tool codex --evidence "..."\` immediately at each real transition through this pipeline — ${currentLane.pipeline_nodes.map((n) => n.id).join(" -> ")}. Then, your task:\n\n`
: ""
: currentLane?.pipeline_skill || "";
useEffect(() => {
const next = promptPrefill && laneTool === "claude" ? `${promptPrefill} ` : promptPrefill;
setPrompt((p) => (p === lastPrefill.current ? next : p));
lastPrefill.current = next;
// `laneId` is a dep too: switching between two lanes on the SAME template
// leaves `promptPrefill` unchanged, and the switch effect just cleared the
// prompt — without it the new lane would open with an empty box.
}, [promptPrefill, laneTool, laneId]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
// run starts, which is an extra prompt nobody asked for. The prompt is the
// user's to type; the pipeline is declared by `agenthub stage` instead.
// The switch effect above only sees whatever `activeRuns` the page already
// had loaded at that instant. Remounting this page (navigating away and
@@ -271,12 +255,16 @@ export function LaneConsolePane({
resumeLast: args.resumeLast,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
// A code-only check: the server's message is human prose ("lane
// already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
}
@@ -332,13 +320,34 @@ export function LaneConsolePane({
onLaneCreated(ensureResult.lane);
}
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
} catch (laneErr: unknown) {
// The lane's own pane is still busy with a PREVIOUS live session —
// that session is still active and reachable, so land on it
// instead of failing outright. Killing it (Active runs → Kill) is
// what actually frees the lane for this resume. Checked by `.code`
// — the server's `.message` is prose, never the literal string
// "ERUNLIVE" or "409".
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
onLaneIdChange(targetLaneId);
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
throw new Error("No run_id returned from lane start");
@@ -368,7 +377,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
@@ -385,6 +394,28 @@ export function LaneConsolePane({
setError(null);
}, []);
// Frees a lane blocked by a live run nobody wants anymore (see Active
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
const killRun = useCallback(
async (id: string) => {
setBusy("kill");
setError(null);
try {
await api.run.kill(id);
if (handle?.id === id) newRun();
refreshList();
} catch (err) {
setError(
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
);
} finally {
setBusy(null);
}
},
[handle, newRun, refreshList, t]
);
if (laneId === null && showLaneSelector) {
return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
@@ -426,13 +457,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,7 +482,9 @@ 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}
@@ -457,6 +494,7 @@ export function LaneConsolePane({
externalSessions={externalSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onKill={killRun}
onRefresh={refreshList}
/>
</header>
@@ -510,6 +548,13 @@ export function LaneConsolePane({
<TerminalView
runId={handle!.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// go straight back to the setup form rather than leaving a dead
// terminal the user has to click "New run" out of.
onExit={() => {
newRun();
refreshList();
}}
/>
<button
onClick={newRun}
+62 -12
View File
@@ -10,18 +10,23 @@
* - `RunsModal` — the merged list itself: live in-memory handles from
* `activeRuns` deduped against persistent `runHistory`, newest first, with
* status / mode chip filters, a free-text search, and the per-row Attach /
* Resume / View actions.
* Resume / View / Kill actions.
*
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "Active runs" counts everything actually running. They carry no tmux session
* the dashboard can attach to, so their only action is Resume, which spawns a
* fresh tmux-backed `claude --resume <session>` in that cwd.
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
* from them for the same reason.
*
* Props only: no API call of its own. The page passes `activeRuns`,
* `runHistory` and `externalSessions` in and gets attach / resume / view /
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
* just calls `onRefresh`.
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
* runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
* discarding a file in the Changes panel) is what actually frees a lane whose
* pane is still busy with a previous session — `LaneConsolePane` needs it
* because starting or resuming into a lane already holding a live run answers
* 409 ERUNLIVE rather than overwriting a prompt in flight.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -39,6 +44,7 @@ import {
Search,
RotateCcw,
Eye,
Square,
} from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import type { Session } from "../../lib/types";
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
externalSessions = [],
onResumeFromHistory,
onViewFromHistory,
onKill,
onRefresh,
}: {
activeRuns: RunListResponse | null;
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
externalSessions?: Session[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
* spawned rows only — an external session has no tmux session here to
* kill. */
onKill: (id: string) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
setOpen(false);
onViewFromHistory(item);
}}
onKill={onKill}
runHistory={historyItems}
onClose={() => setOpen(false)}
onRefresh={onRefresh}
@@ -273,6 +285,7 @@ export function RunsModal({
onAttach,
onResume,
onView,
onKill,
runHistory,
onClose,
onRefresh,
@@ -282,6 +295,7 @@ export function RunsModal({
onAttach: (id: string) => void;
onResume: (item: DashboardRunHistoryItem) => void;
onView: (item: DashboardRunHistoryItem) => void;
onKill: (id: string) => void;
runHistory: DashboardRunHistoryItem[];
onClose: () => void;
onRefresh: () => void;
@@ -446,6 +460,7 @@ export function RunsModal({
const h = historyById.get(r.id);
if (h) onView(h);
}}
onKill={() => onKill(r.id)}
/>
);
})
@@ -512,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.
@@ -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 && (
+99 -82
View File
@@ -36,6 +36,7 @@ import {
ShieldAlert,
X,
FolderOpen,
GitBranch,
Home,
History as HistoryIcon,
Search,
@@ -76,7 +77,9 @@ interface PromptEditorProps {
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSubmit();
}
@@ -147,9 +150,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 +244,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 +273,8 @@ export function RunSetup(props: RunSetupProps) {
</button>
)}
</div>
)}
</div>
</div>
)}
{/* Prompt */}
<div className="px-4 py-3 border-b border-border">
@@ -214,78 +295,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 +364,10 @@ function handleStart(props: RunSetupProps, session?: Session) {
effort: props.effort || undefined,
resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
initialPrompt: props.prompt || undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined,
});
}
@@ -440,7 +452,10 @@ export function CwdAutocomplete({
// Group suggestions by kind preserving fixed order — home first, matching
// the neutral default the page pre-fills (issue #202).
const groups = useMemo(() => {
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
// "repo" (source repos lanes were cut from) sits above the recents: on the
// Add-lane form it is the group the field is actually asking for, and on
// the Run form it is a short, deliberate list rather than history.
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"];
return order
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
.filter((g) => g.items.length > 0);
@@ -516,6 +531,8 @@ export function CwdAutocomplete({
<FolderOpen className="w-3 h-3" />
) : g.kind === "home" ? (
<Home className="w-3 h-3" />
) : g.kind === "repo" ? (
<GitBranch className="w-3 h-3" />
) : (
<HistoryIcon className="w-3 h-3" />
)}
+33 -3
View File
@@ -20,10 +20,19 @@ import "@xterm/xterm/css/xterm.css";
interface TerminalViewProps {
runId: string;
wsBaseUrl: string;
/** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit).
* There is nothing left to attach to, so the owner swaps this terminal back
* out for its run setup form instead of leaving a dead pane on screen. */
onExit?: () => void;
}
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
// every render, and as an effect dep it would tear the terminal down and
// re-attach on each one.
const onExitRef = useRef(onExit);
onExitRef.current = onExit;
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -35,8 +44,15 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too.
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
// cursor to column 0 on a line feed — and xterm's own docs say not to use
// it on a PTY, where termios already does that translation for the streams
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
// drawing at the column it was on; with this on, that draw landed at
// column 0 instead, stamping the first characters of the line into the
// left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled.
const term = new Terminal({
convertEol: true,
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
@@ -63,6 +79,10 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
}
fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor.
term.focus();
// When the pane's program has NOT enabled wheel reporting, xterm falls back
// to converting each wheel notch on an alt-screen buffer into a cursor-key
@@ -116,7 +136,16 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
let disposed = false;
const connect = () => {
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
// The attach itself carries this terminal's size. tmux sizes the window
// to the client that attached most recently, so spawning the attach at a
// default 80×24 and correcting it from the first `resize` message a beat
// later makes the pane's program (claude) redraw its whole UI at 80
// columns first — and everything painted at that width stays in the
// scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each 1.5 s reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
@@ -143,6 +172,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
if (msg.type === "exit") {
ended = true;
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
onExitRef.current?.();
}
return;
} catch {
@@ -6,13 +6,18 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
vi.mock("../TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
// out of claude), which the real TerminalView reports through `onExit`.
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
<div data-testid="terminal-view" data-run-id={runId}>
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
</div>
),
}));
@@ -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)", () => {
@@ -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")));
@@ -121,8 +121,10 @@ describe("RunSetup — selections report through callbacks", () => {
it("reports the model from the picker, including a custom id", () => {
const { spies } = renderSetup();
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 5");
expect(spies.onModelChange).toHaveBeenCalledWith("claude-sonnet-5");
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
// what keeps the list from going stale on every model release.
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
// the free-text box it reveals is what reports.
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
@@ -234,6 +236,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
);
});
it("sends no prompt when resuming, even with text in the box", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "leftover text" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
// The resumed session is the input; anything left in the box would be an
// extra turn the user never asked for.
expect(spies.onStart.mock.calls[0]?.[0]?.initialPrompt).toBeUndefined();
});
it("falls back to the typed cwd when no lane is selected", async () => {
const { api } = await import("../../../lib/api");
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
@@ -16,6 +16,7 @@ const openMock = vi.fn();
const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
const termState = {
@@ -54,6 +55,7 @@ vi.mock("@xterm/xterm", () => ({
loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
})),
}));
vi.mock("@xterm/addon-fit", () => ({
@@ -100,13 +102,27 @@ describe("TerminalView", () => {
keyHandlerMock.mockClear();
loadAddonMock.mockClear();
openMock.mockClear();
focusMock.mockClear();
});
afterEach(cleanup);
it("opens a WS connection to the run's ws-pty path", () => {
it("opens a WS connection to the run's ws-pty path, carrying its own size", () => {
// The size rides on the attach URL, not on a `resize` message sent after
// it: tmux resizes the window to the newest client, so attaching at the
// server's default 80x24 makes claude redraw its UI at 80 columns and
// leaves that width in the scrollback (issue #33).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
});
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
termState.cols = 0;
termState.rows = 0;
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
});
@@ -184,6 +200,24 @@ describe("TerminalView", () => {
expect(preventPlainTab).not.toHaveBeenCalled();
});
it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane.
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(focusMock).toHaveBeenCalled();
});
it("tells its owner when the pane exited, so the setup form can come back", () => {
// A double Ctrl+C out of `claude` ends the tmux session; the dead pane used
// to sit there until the user found the "New run" button.
const onExit = vi.fn();
render(
<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" onExit={onExit} />
);
MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' });
expect(onExit).toHaveBeenCalledTimes(1);
});
it("renders through the canvas addon, not xterm's DOM renderer", () => {
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
@@ -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,
@@ -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}
@@ -853,7 +853,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
ref={tipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
className="fixed z-50 px-3 py-2 bg-surface-2 border border-border rounded-lg shadow-2xl pointer-events-none"
style={{
display: "none",
opacity: 0,
@@ -922,10 +922,11 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
row.style.cssText =
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
const lbl = document.createElement("span");
lbl.style.color = "#64748b";
lbl.style.color = "rgb(var(--fg-muted))";
lbl.textContent = label;
const val = document.createElement("span");
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
val.style.cssText =
"color:rgb(var(--fg-primary));font-weight:500;font-variant-numeric:tabular-nums";
val.textContent = value;
row.appendChild(lbl);
row.appendChild(val);
@@ -935,12 +936,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
const desc = document.createElement("p");
desc.style.cssText =
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
"font-size:11px;color:rgb(var(--fg-secondary));line-height:1.45;border-top:1px solid rgb(var(--border));padding-top:8px;margin:0";
desc.textContent = meta.description;
el.appendChild(desc);
const hint = document.createElement("p");
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
hint.style.cssText = "font-size:10px;color:rgb(var(--fg-muted));line-height:1.45;margin:6px 0 0";
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
el.appendChild(hint);
}
@@ -115,7 +115,7 @@ function Tooltip({ state }: { state: TooltipState }) {
return (
<div
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
className="fixed z-50 px-3 py-2 text-xs bg-surface-2 border border-border rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? state.x - 12 : state.x + 12,
top: state.y - 10,
@@ -144,7 +144,7 @@ function Tooltip({ state }: { state: TooltipState }) {
</span>
)}
</div>
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
<div className="mt-1 pt-1 border-t border-border">
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
</span>
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
return (
<div>
<div
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
>
{/* Depth connector line */}
@@ -252,7 +252,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{events.map((ev) => (
<div
key={ev.id}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
>
{/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
{recent.map((ev) => (
<div
key={ev.id}
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
>
{/* Event type badge */}
<span
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
key={s.id}
type="button"
onClick={() => handleSelect(s.id)}
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
>
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button"
onClick={handleLoadMore}
disabled={loading}
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
>
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button>
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.close")}
>
<X className="w-3.5 h-3.5" />
@@ -230,10 +230,10 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-border">
<span className="text-base font-semibold text-fg-primary tabular-nums">
{valueDisplay}
</span>
@@ -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);
});
});
+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;
+5
View File
@@ -83,6 +83,8 @@ import updates_en from "./locales/en/updates.json";
import updates_vi from "./locales/vi/updates.json";
import ccConfig_en from "./locales/en/ccConfig.json";
import ccConfig_vi from "./locales/vi/ccConfig.json";
import codexConfig_en from "./locales/en/codexConfig.json";
import codexConfig_vi from "./locales/vi/codexConfig.json";
import run_en from "./locales/en/run.json";
import run_vi from "./locales/vi/run.json";
import alerts_en from "./locales/en/alerts.json";
@@ -110,6 +112,7 @@ i18n
errors: errors_en,
updates: updates_en,
ccConfig: ccConfig_en,
codexConfig: codexConfig_en,
run: run_en,
alerts: alerts_en,
splash: splash_en,
@@ -128,6 +131,7 @@ i18n
errors: errors_vi,
updates: updates_vi,
ccConfig: ccConfig_vi,
codexConfig: codexConfig_vi,
run: run_vi,
alerts: alerts_vi,
splash: splash_vi,
@@ -150,6 +154,7 @@ i18n
"errors",
"updates",
"ccConfig",
"codexConfig",
"run",
"alerts",
"splash",
+55 -6
View File
@@ -129,6 +129,10 @@
"mcp": {
"userScope": "User-scoped servers",
"projectScope": "Project-scoped servers",
"name": "Name",
"scope": "Scope",
"scopeUser": "User",
"scopeLocal": "This project",
"command": "Command",
"args": "Args",
"env": "Env names",
@@ -136,7 +140,8 @@
"headers": "Header names",
"kind": "Kind",
"source": "Source",
"noServers": "No MCP servers found."
"noServers": "No MCP servers found.",
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
},
"hooks": {
"noHooks": "No hooks configured.",
@@ -169,6 +174,20 @@
"projectLabel": "Project",
"projectHelp": "Created in this project's memory dir."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"edit": {
"newButton": "New",
"editButton": "Edit",
@@ -190,7 +209,7 @@
"saveSuccessNew": "Created.",
"deleteSuccess": "Deleted · backup at {{path}}",
"confirmDelete": "Delete this item?",
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories Claude Code scans, so the entry will disappear from your CLI.",
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories the CLI scans, so the entry will disappear from your CLI.",
"confirmDeletePath": "Path: {{path}}",
"confirmDeleteAction": "Yes, back up and delete",
"templates": {
@@ -216,6 +235,14 @@
"filterAll": "All types"
},
"profiles": {
"usageCheck": "Check quota",
"usageChecking": "Checking…",
"usageRefresh": "Refresh",
"usageRetry": "Try again",
"usageFiveHour": "5h",
"usageSevenDay": "7d",
"usageResets": "Resets {{time}}",
"usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.",
"title": "Claude Code profiles",
"openButton": "Switch profile",
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
@@ -275,10 +302,7 @@
"body": "MCP server config lives inside `~/.claude.json`, the same file the live CLI writes to constantly. Concurrent writes from the dashboard would corrupt project state, so add/remove flow goes through Claude Code itself.",
"list": "List configured servers:",
"listCmd": "claude mcp list",
"add": "Add a server:",
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Remove this server:",
"removeButton": "Copy remove command"
"add": "Add a server:"
},
"marketplaces": {
"title": "Marketplaces are where plugins come from",
@@ -289,6 +313,21 @@
"copy": "Copy",
"copied": "Copied"
},
"updateAll": {
"title": "Update everything",
"help": "Refreshes every marketplace, then updates all installed plugins, then tells the Claude Code sessions this dashboard has open to pick up the changes. AgentHub itself goes last, because updating it restarts this dashboard.",
"button": "Update all",
"working": "Updating…",
"started": "Update-all started for {{count}} plugin(s).",
"failed": "Could not start the update.",
"reloadButton": "Reload sessions",
"reloaded": "Reload sent to {{count}} open session(s).",
"reloadedNone": "No open Claude Code session to reload.",
"reloadedCount": "— {{count}} session(s)",
"restartWarning": "Keep this tab open. If AgentHub itself is updated, the dashboard restarts at the end and this page reconnects on its own.",
"abandoned": "The previous batch stopped without finishing — most likely the machine restarted. Run it again.",
"bootstrapFailed": "The dashboard was stopped but did not come back: {{error}}. Run /agenthub-update to recover."
},
"manage": {
"installTitle": "Install a plugin",
"installPlaceholder": "owner/repo, https://… or plugin@marketplace",
@@ -317,6 +356,16 @@
"marketplaceAdded": "Added marketplace {{name}}.",
"marketplaceUpdated": "Updated marketplace {{name}}.",
"marketplaceRemoved": "Removed marketplace {{name}}.",
"addMcpTitle": "Add an MCP server",
"addMcpNamePlaceholder": "name",
"addMcpTransportStdio": "Command (stdio)",
"addMcpTransportHttp": "URL (http)",
"addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github",
"addMcpUrlPlaceholder": "https://mcp.example.com",
"addMcpButton": "Add",
"addMcpHelp": "Runs `claude mcp add`. \"This project\" scopes the server to the current checkout — the CLI's separate `project` scope (a shared `.mcp.json`) isn't offered here because this dashboard can't see servers stored there.",
"mcpAdded": "Added MCP server {{name}}.",
"mcpRemoved": "Removed MCP server {{name}}.",
"hookEvent": "Event name (e.g. PreToolUse)",
"hookAddEvent": "Add event",
"hookRemoveEvent": "Remove event",
+123
View File
@@ -0,0 +1,123 @@
{
"title": "Codex Config Explorer",
"subtitle": "Your Codex CLI setup at a glance - skills, agents, rules, AGENTS.md memory, config.toml, MCP servers, plugins and marketplaces.",
"refresh": "Refresh",
"refreshing": "Refreshing…",
"lastUpdated": "Last updated {{time}}",
"loadError": "Failed to load: {{message}}",
"scope": {
"all": "All scopes",
"user": "User",
"project": "Project"
},
"tabs": {
"overview": "Overview",
"skills": "Skills",
"agents": "Agents",
"rules": "Rules",
"memory": "Memory",
"config": "Config",
"mcp": "MCP servers",
"plugins": "Plugins"
},
"overview": {
"rootsTitle": "Roots",
"codexHome": "Codex home",
"projectCodexDir": "Project .codex",
"projectRoot": "Project root",
"summary": "Quick summary",
"user": "user",
"project": "project"
},
"common": {
"search": "Search by name…",
"clearSearch": "Clear search",
"close": "Close"
},
"list": {
"newButton": "New",
"viewButton": "View source",
"editButton": "Edit",
"deleteButton": "Delete"
},
"edit": {
"saveSuccess": "Saved · {{path}}",
"saveSuccessNew": "Created.",
"deleteSuccess": "Deleted · {{path}}",
"deleteError": "Delete failed: {{message}}",
"templates": {
"skills": "---\nname: my-skill\ndescription: One-line description (shown when Codex picks the skill)\n---\n\nBody - what the skill does, when to use it, examples.\n",
"agents": "name = \"my-agent\"\ndescription = \"When to invoke this agent\"\n\ninstructions = \"\"\"\nSystem prompt for the agent.\n\"\"\"\n",
"rules": "# Rules\n\nCodex follows these rules for this scope.\n",
"memory": "# Project memory\n\nCodex reads this on session start.\n",
"config": "# config.toml\n"
}
},
"memory": {
"missing": "No AGENTS.md found."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"config": {
"missing": "No config.toml found."
},
"mcp": {
"addTitle": "Add an MCP server",
"nameLabel": "Name",
"modeCommand": "Command",
"modeUrl": "URL",
"commandLabel": "Command",
"argsLabel": "Args (space-separated)",
"urlLabel": "URL",
"bearerLabel": "Bearer token env var (optional)",
"addButton": "Add",
"removeButton": "Remove",
"added": "Added {{name}}.",
"removed": "Removed {{name}}."
},
"plugins": {
"installTitle": "Install a plugin",
"installPlaceholder": "name or name@marketplace",
"installButton": "Add",
"removeButton": "Remove",
"installed": "Installed {{name}}.",
"removed": "Removed {{name}}.",
"noPlugins": "No plugins installed.",
"marketplacesTitle": "Marketplaces",
"addMarketplaceTitle": "Add a marketplace",
"addMarketplacePlaceholder": "https://github.com/owner/repo",
"addMarketplaceButton": "Add",
"marketplaceAdded": "Added marketplace.",
"marketplaceRemoved": "Removed marketplace {{name}}."
},
"manage": {
"working": "Working…",
"noIdentifier": "No stable identifier for this entry - remove it from the CLI directly."
},
"cli": {
"unavailable": "The codex CLI isn't available on this machine ({{message}}) - install it or add it to PATH to manage this."
},
"backups": {
"openButton": "Backups",
"title": "Backups",
"subtitle": "Every save / delete writes a copy here first. Backup folders live outside the directories the CLI scans, so they don't reappear as ghosts in your CLI.",
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
"restoreHint": "Restore by renaming the backup back into place:"
},
"snippet": {
"copy": "Copy",
"copied": "Copied"
}
}
+8 -7
View File
@@ -7,16 +7,17 @@
"goBack": "Go Back"
},
"notifications": {
"awaitingInput": "Claude needs your confirmation",
"defaultBody": "Notification",
"defaultTitle": "Claude Code",
"finishedResponding": "Claude Finished Responding",
"newSession": "New Session",
"readyForInput": "Ready for input",
"sessionClosed": "Session closed",
"sessionCompleted": "Session Completed",
"sessionDefault": "Session ",
"sessionError": "Session Error",
"subagentSpawned": "Subagent Spawned",
"finishedResponding": "Claude Finished Responding",
"readyForInput": "Ready for input",
"sessionCompleted": "Session Completed",
"sessionClosed": "Session closed",
"defaultTitle": "Claude Code",
"defaultBody": "Notification"
"subagentSpawned": "Subagent Spawned"
},
"toolLegend": {
"read": "Read",
+139 -33
View File
@@ -2,7 +2,6 @@
"action.clear": "clear state",
"action.forget": "Delete lane",
"action.purge": "delete history",
"action.remove": "delete lane + worktree",
"action.reset": "reset worktree",
"action.start": "start",
"action.stop": "stop",
@@ -17,42 +16,105 @@
"actions.syncCheckClean": "DEV_DELTA: {{count}} file(s), overlap: {{overlap}}",
"actions.syncCheckCollision": "Migration collision: {{file}} → rename to {{suggestion}}",
"actions.tools": "Tools",
"actions.writeDocs": "Create/update readme file",
"actions.writeDocsHint": "Opens this lane's terminal and runs /sg-all:sg-md-writing there: creates CLAUDE.md/AGENTS.md if missing, or updates them concisely if they already exist.",
"actions.writeDocsResult": "Started — see this lane's terminal.",
"add": "Add lane",
"addLane": "Create a lane from a working directory",
"addLaneBaseLabel": "Branch to fork from",
"addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.",
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
"addLaneBranchExistingLabel": "Branch to check out",
"addLaneBranchHintAuto": "Leave empty to use the default name shown above.",
"addLaneBranchLabel": "New branch name",
"addLaneBranchModeCurrent": "Current branch",
"addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.",
"addLaneBranchModeExisting": "Existing branch",
"addLaneBranchModeNew": "New branch",
"addLaneBranchPlaceholder": "feat/my-feature",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
"addLanePipelineHint": "Which stages this lane's map shows. Pick the one the skill driving it declares against — nothing else in this UI changes it later.",
"addLanePipelineLabel": "Pipeline template",
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
"addLaneRouterLabel": "Model endpoint",
"addLaneRouterLoading": "Loading router profiles…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Router profile",
"addLaneSetupAgents": "Agents",
"addLaneSetupClose": "Close",
"addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Model endpoint",
"addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title",
"addLaneTitlePlaceholder": "Optional",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
"browse.button": "Browse",
"browse.empty": "This folder is empty.",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees",
"addLaneTitlePlaceholder": "Optional",
"autoStage": "auto: {{stage}}",
"cardId": "Lane {{id}}",
"changes.back": "All files",
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
"changes.collapse": "Collapse the changes panel",
"changes.collapseAll": "Collapse every file",
"changes.collapseAllFolders": "Collapse all folders",
"changes.collapseContext": "Show fewer surrounding lines",
"changes.collapseFile": "Collapse {{path}}",
"changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged",
"changes.conflicted": "conflict",
"changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone",
"changes.empty.branch": "This branch matches its base.",
"changes.empty.commit": "This commit changed no files.",
"changes.empty.work": "Nothing uncommitted.",
"changes.expand": "Show the changes panel",
"changes.expandAll": "Expand every file",
"changes.expandAllFolders": "Expand all folders",
"changes.expandContext": "Show more surrounding lines",
"changes.expandFile": "Expand {{path}}",
"changes.fileGone": "That file no longer has changes in this range.",
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
"changes.layout.split": "Side-by-side",
"changes.layout.unified": "Unified",
"changes.loading": "Reading the working copy…",
"changes.mode.browse": "Files",
"changes.mode.history": "History",
"changes.mode.overview": "Overview",
"changes.mode.review": "Review",
"changes.noCommits": "No commits yet.",
"changes.noHunks": "No textual change.",
"changes.noMatches": "No tracked file matches.",
"changes.openFile": "Open {{path}}",
"changes.quickOpen": "Find a file by path…",
"changes.range.branch": "vs base",
"changes.range.branchHint": "base...HEAD — everything this lane's branch adds on top of its base",
"changes.range.work": "Uncommitted",
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
"changes.refresh": "Refresh",
"changes.resize": "Resize the changes panel (next: {{size}})",
"changes.size.compact": "Narrow",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This lane's directory is not a readable git repository.",
"changes.unstage": "Unstage this file",
"changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.",
@@ -77,33 +139,83 @@
"destructive.loading": "Loading current facts…",
"destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.",
"destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.",
"destructive.notice.missing": "The lane directory is already gone. Removing it drops the dashboard's record and clears Git's stale worktree entry; nothing is deleted from disk.",
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched.",
"destructive.notice.missing": "The lane directory is already gone. Deleting the lane drops the dashboard's record only; nothing on disk and no Git record is touched.",
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. That changes nothing here: only the dashboard's record is deleted, the directory is never touched.",
"destructive.purge.confirm": "Purge history",
"destructive.purge.message": "Approve the current counts before deleting stored session history.",
"destructive.purge.title": "Purge lane history?",
"destructive.remove.confirm": "Remove lane",
"destructive.remove.message": "Approve the current counts before this action.",
"destructive.remove.title": "Remove lane?",
"destructive.remove.confirm": "Delete lane record",
"destructive.remove.message": "Only the dashboard's record of this lane is deleted. Its working tree, its branch and its database are left exactly as they are — delete them yourself if you want them gone.",
"destructive.remove.title": "Delete lane record?",
"destructive.reset.confirm": "Reset worktree",
"destructive.reset.message": "Approve the current counts before resetting this worktree.",
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
"destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"emptyState": "No lanes yet. Create one from a working directory:",
"empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
"empty.title": "No lanes yet",
"features.archived": "archived",
"features.live": "Live",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"git.filesHeading_one": "{{count}} file changed",
"git.filesHeading_other": "{{count}} files changed",
"git.filesMore_one": "… and {{count}} more file",
"git.filesMore_other": "… and {{count}} more files",
"git.noDelta": "new",
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
"gitBar.branchCreate": "Create",
"gitBar.branchCreateHint": "Create this branch without switching to it",
"gitBar.branchDelete": "Delete",
"gitBar.branchDeleteHint": "Delete the selected branch (refused if it has unmerged commits)",
"gitBar.branchNewPlaceholder": "New branch name…",
"gitBar.cancel": "Cancel",
"gitBar.commit": "Commit",
"gitBar.commitAll_one": "Commit {{count}} file",
"gitBar.commitAll_other": "Commit {{count}} files",
"gitBar.commitHint": "Stage everything and commit",
"gitBar.commitNothing": "Nothing to commit",
"gitBar.commitPlaceholder": "Commit message",
"gitBar.commitStaged": "Commit staged",
"gitBar.commitStagedHint": "Commit only what is staged in the Changes panel",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.inSync": "in sync",
"gitBar.newLane": "New lane here",
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
"gitBar.noUpstream": "not pushed",
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
"gitBar.push": "Push",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.switchCurrent": "current",
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
"gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…",
"kind.adopted": "adopted",
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
"preflightError": "Could not load the current lane facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
"proof.ticketReport": "Task report",
"renameLabel": "Rename this lane",
"reorderHint": "Drag to reorder · Alt+←/→",
"router.applied": "Applied.",
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
"router.label": "Model",
"router.restart": "Restart",
"router.restarted": "Session restarted with the new router.",
"runtime.boot": "▶ up",
"runtime.busy.down": "stopping…",
"runtime.busy.up": "booting…",
@@ -114,23 +226,17 @@
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
"runtime.stop": "■ down",
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
"integrations.ci_wait": "CI wait",
"integrations.dev_qc": "dev QC",
"integrations.tracker": "tracker",
"stageUndeclared": "not declared",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneCount": "{{count}} pane",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.pickLane": "Pick a lane",
"status.failed": "failed",
"status.idle": "idle",
"status.provisioning": "provisioning",
"status.running": "running",
"features.live": "Live",
"features.archived": "archived",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"proof.ticketReport": "Task report",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.paneCount": "{{count}} pane",
"splitView.pickLane": "Pick a lane",
"statusDead": "DEAD",
"title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees"
}
+5 -3
View File
@@ -3,8 +3,8 @@
"agentBoard": "Kanban Board",
"analytics": "Analytics",
"avg": "avg",
"brand": "Agent Dashboard",
"brandSub": "Claude Code Monitor",
"brand": "AgentHub",
"brandSub": "SmartGift Solution",
"ccConfig": "Claude Config",
"checkFailed": "Update check failed",
"checkForUpdates": "Check for updates",
@@ -65,5 +65,7 @@
"website": "Website",
"workflows": "Workflows",
"workspace": "Workspace",
"wsEndpoint": "WebSocket"
"wsEndpoint": "WebSocket",
"releaseAvailable": "{{version}} available",
"devBuild": "Dev build · {{count}} ahead"
}
+6 -3
View File
@@ -34,6 +34,7 @@
},
"errors": {
"attachFailed": "Couldn't attach to run: {{message}}",
"attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
"cwdRequired": "Working directory required to start a run",
"killFailed": "Couldn't stop: {{message}}",
"laneCreateFailed": "Failed to create lane: {{message}}",
@@ -61,6 +62,7 @@
"cwdGroups": {
"dashboard": "Dashboard server",
"home": "Home",
"repo": "Source repos",
"recent": "Recent - used by Claude Code"
},
"cwdHint": "Absolute path. Defaults to the dashboard's own cwd.",
@@ -71,7 +73,8 @@
"modelCustom": "Custom…",
"modelCustomPlaceholder": "model id (e.g. claude-opus-5)",
"modelInheritLabel": "Inherit from settings",
"permissionAcceptEdits": "acceptEdits (recommended)",
"permissionAcceptEdits": "acceptEdits (file edits without asking)",
"permissionAuto": "auto (recommended - asks only for risky actions)",
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode",
@@ -95,7 +98,7 @@
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter to send"
"shortcut": "Enter to send · Shift+Enter for newline"
},
"limitations": {
"dismiss": "Got it"
@@ -153,7 +156,7 @@
"permissionMode": "Mode",
"session": "Session"
},
"subtitle": "Spin up a {{tool}} session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.",
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
"title": "Run {{tool}}",
"tokens": {
"cacheRead": "Cache hit",
+56 -11
View File
@@ -71,21 +71,27 @@
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
},
"notifications": {
"title": "Notifications",
"awaitingInput": "Claude needs confirmation",
"blocked": "Permission blocked",
"description": "Browser notifications for important events. Requires permission.",
"disabledInfo": "Notifications are disabled - enable to get alerts for session events",
"enable": "Enable Browser Notifications",
"granted": "Permission granted",
"blocked": "Permission blocked",
"required": "Permission required",
"notifyWhen": "Notify me when...",
"newSession": "New session starts",
"notifyWhen": "Notify me when...",
"required": "Permission required",
"sendTest": "Send Test Notification",
"sessionComplete": "Session completes",
"sessionError": "Session errors",
"subagentSpawned": "Subagent spawned",
"testTitle": "Agent Monitor",
"testBlocked": "The browser has not granted notification permission.",
"testBody": "Notifications are working!",
"sendTest": "Send Test Notification",
"disabledInfo": "Notifications are disabled - enable to get alerts for session events"
"testFailed": "Nothing could be shown.",
"testLocal": "Your browser has no push subscription, so this was shown locally. Web Push needs a secure origin (localhost or HTTPS) and a registered service worker.",
"testSent": "Sent via push — check your notification centre.",
"testTitle": "Agent Monitor",
"title": "Notifications",
"turnComplete": "Claude finishes a turn"
},
"import": {
"title": "Import History",
@@ -446,9 +452,48 @@
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"cursor": "Cursor",
"soonBadge": "soon",
"comingSoon": "{{tool}} is not integrated yet",
"comingSoonDesc": "Its configuration will show up here once support lands."
"aiRouter": "AI Router"
},
"aiRouter": {
"title": "AI Router",
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a lane.",
"add": "Add profile",
"edit": "Edit",
"remove": "Remove",
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
"colModel": "Model",
"colKey": "API key",
"keySet": "Saved",
"keyMissing": "None",
"fieldLabel": "Name",
"fieldLabelHint": "What you will see in the lane form. Free text.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
"perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.",
"tier": {
"opus": "Opus",
"sonnet": "Sonnet",
"haiku": "Haiku",
"smallFast": "Small / fast",
"subagent": "Subagent"
},
"save": "Save",
"cancel": "Cancel",
"removeConfirm": "Remove the profile \"{{label}}\"? Every lane using it is reset to the vendor's own API right away — on disk and in the dashboard.",
"loading": "Loading profiles…",
"lanesFailed": "Saved, but {{count}} lane could not be updated and still carries the old endpoint: {{lanes}}",
"test": "Test",
"testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"tagline": "Nothing your agents do stays in the dark.",
"sub1": "Real-time observability for every Claude Code session.",
"sub2": "Tokens, tools, costs, and workflows, all live.",
"brand": "Claude Code Monitor",
"brand": "SmartGift Solution",
"taglines": [
"Nothing your agents do stays in the dark.",
"See every move your agents make.",
+11 -1
View File
@@ -16,5 +16,15 @@
"updating": "Updating...",
"restarting": "Rebuilt — restarting the dashboard...",
"applyError": "Could not apply the update",
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually."
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually.",
"releaseTitle": "Release {{version}} available",
"releaseSubtitle": "You are running {{current}}",
"releaseLead": "A new release of the dashboard has been published. \"Update now\" fast-forwards this install onto the release tag, reinstalls dependencies, rebuilds, and restarts the server.",
"viewRelease": "Release notes",
"reasonPluginManaged": "This install is managed by the Claude Code plugin cache — run /agenthub-update instead.",
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
"restartingShort": "Restarting…"
}
+3 -1
View File
@@ -246,7 +246,9 @@
"never": "never reached",
"copyCommand": "Copy: {{command}}",
"empty": "No lane has recorded a stage yet. Create one with",
"emptyLane": "This lane has not recorded a stage yet."
"emptyLane": "This lane has not recorded a stage yet.",
"legendDetected": "reached, not declared",
"detectedOnly": "detected ×{{count}}"
},
"errorPropagation": {
"title": "Error Propagation Map",
+55 -6
View File
@@ -124,6 +124,10 @@
"mcp": {
"userScope": "Máy chủ phạm vi người dùng",
"projectScope": "Máy chủ phạm vi dự án",
"name": "Tên",
"scope": "Phạm vi",
"scopeUser": "Người dùng",
"scopeLocal": "Dự án này",
"command": "Lệnh",
"args": "Tham số",
"env": "Tên env",
@@ -131,7 +135,8 @@
"headers": "Tên Header",
"kind": "Loại",
"source": "Nguồn",
"noServers": "Không tìm thấy máy chủ MCP."
"noServers": "Không tìm thấy máy chủ MCP.",
"notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này."
},
"hooks": {
"noHooks": "Chưa cấu hình hook.",
@@ -164,6 +169,20 @@
"projectLabel": "Dự án",
"projectHelp": "Được tạo trong thư mục bộ nhớ của dự án này."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"edit": {
"newButton": "Tạo mới",
"editButton": "Sửa",
@@ -185,7 +204,7 @@
"saveSuccessNew": "Đã tạo.",
"deleteSuccess": "Đã xoá · bản sao tại {{path}}",
"confirmDelete": "Xoá mục này?",
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục Claude Code quét, nên mục này sẽ biến mất khỏi CLI.",
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục CLI quét, nên mục này sẽ biến mất khỏi CLI.",
"confirmDeletePath": "Đường dẫn: {{path}}",
"confirmDeleteAction": "Xác nhận sao lưu và xoá",
"templates": {
@@ -211,6 +230,14 @@
"filterAll": "Tất cả loại"
},
"profiles": {
"usageCheck": "Kiểm tra quota",
"usageChecking": "Đang kiểm tra…",
"usageRefresh": "Làm mới",
"usageRetry": "Thử lại",
"usageFiveHour": "5h",
"usageSevenDay": "7 ngày",
"usageResets": "Reset lúc {{time}}",
"usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.",
"title": "Profile Claude Code",
"openButton": "Đổi profile",
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
@@ -270,10 +297,7 @@
"body": "Cấu hình MCP server nằm trong `~/.claude.json`, đúng tệp mà CLI đang chạy ghi liên tục. Ghi đồng thời từ dashboard sẽ phá hỏng trạng thái dự án, nên flow add/remove đi qua chính Claude Code.",
"list": "Liệt kê server đã cấu hình:",
"listCmd": "claude mcp list",
"add": "Thêm server:",
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Gỡ server này:",
"removeButton": "Sao chép lệnh gỡ"
"add": "Thêm server:"
},
"marketplaces": {
"title": "Marketplace là nơi plugin đến từ đó",
@@ -284,6 +308,21 @@
"copy": "Sao chép",
"copied": "Đã sao chép"
},
"updateAll": {
"title": "Cập nhật toàn bộ",
"help": "Làm mới mọi marketplace, rồi cập nhật tất cả plugin đã cài, sau đó yêu cầu các session Claude Code mà dashboard đang mở nạp lại thay đổi. AgentHub đứng cuối, vì cập nhật nó sẽ khởi động lại dashboard này.",
"button": "Cập nhật tất cả",
"working": "Đang cập nhật…",
"started": "Đã bắt đầu cập nhật {{count}} plugin.",
"failed": "Không khởi động được tiến trình cập nhật.",
"reloadButton": "Nạp lại session",
"reloaded": "Đã gửi lệnh nạp lại tới {{count}} session đang mở.",
"reloadedNone": "Không có session Claude Code nào đang mở để nạp lại.",
"reloadedCount": "— {{count}} session",
"restartWarning": "Giữ tab này mở. Nếu chính AgentHub được cập nhật, dashboard sẽ khởi động lại ở bước cuối và trang tự kết nối lại.",
"abandoned": "Đợt cập nhật trước dừng giữa đường — rất có thể do máy khởi động lại. Hãy chạy lại.",
"bootstrapFailed": "Dashboard đã bị dừng nhưng không sống lại: {{error}}. Chạy /agenthub-update để khôi phục."
},
"manage": {
"installTitle": "Cài plugin",
"installPlaceholder": "owner/repo, https://… hoặc plugin@marketplace",
@@ -312,6 +351,16 @@
"marketplaceAdded": "Đã thêm marketplace {{name}}.",
"marketplaceUpdated": "Đã cập nhật marketplace {{name}}.",
"marketplaceRemoved": "Đã xóa marketplace {{name}}.",
"addMcpTitle": "Thêm máy chủ MCP",
"addMcpNamePlaceholder": "tên",
"addMcpTransportStdio": "Lệnh (stdio)",
"addMcpTransportHttp": "URL (http)",
"addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github",
"addMcpUrlPlaceholder": "https://mcp.example.com",
"addMcpButton": "Thêm",
"addMcpHelp": "Chạy lệnh `claude mcp add`. \"Dự án này\" giới hạn server trong checkout hiện tại — phạm vi `project` riêng của CLI (một `.mcp.json` dùng chung) không được cung cấp ở đây vì dashboard không thể thấy các server lưu ở đó.",
"mcpAdded": "Đã thêm máy chủ MCP {{name}}.",
"mcpRemoved": "Đã xóa máy chủ MCP {{name}}.",
"hookEvent": "Tên sự kiện (vd PreToolUse)",
"hookAddEvent": "Thêm sự kiện",
"hookRemoveEvent": "Xóa sự kiện",
+123
View File
@@ -0,0 +1,123 @@
{
"title": "Trình khám phá cấu hình Codex",
"subtitle": "Toàn cảnh thiết lập Codex CLI của bạn - skill, agent, rule, bộ nhớ AGENTS.md, config.toml, máy chủ MCP, plugin và marketplace.",
"refresh": "Làm mới",
"refreshing": "Đang làm mới…",
"lastUpdated": "Cập nhật lần cuối {{time}}",
"loadError": "Tải thất bại: {{message}}",
"scope": {
"all": "Tất cả phạm vi",
"user": "Người dùng",
"project": "Dự án"
},
"tabs": {
"overview": "Tổng quan",
"skills": "Skill",
"agents": "Agent",
"rules": "Rule",
"memory": "Bộ nhớ",
"config": "Cấu hình",
"mcp": "Máy chủ MCP",
"plugins": "Plugin"
},
"overview": {
"rootsTitle": "Thư mục gốc",
"codexHome": "Thư mục Codex",
"projectCodexDir": "Thư mục .codex của dự án",
"projectRoot": "Thư mục gốc dự án",
"summary": "Tóm tắt nhanh",
"user": "người dùng",
"project": "dự án"
},
"common": {
"search": "Tìm theo tên…",
"clearSearch": "Xóa tìm kiếm",
"close": "Đóng"
},
"list": {
"newButton": "Mới",
"viewButton": "Xem nguồn",
"editButton": "Sửa",
"deleteButton": "Xóa"
},
"edit": {
"saveSuccess": "Đã lưu · {{path}}",
"saveSuccessNew": "Đã tạo.",
"deleteSuccess": "Đã xóa · {{path}}",
"deleteError": "Xóa thất bại: {{message}}",
"templates": {
"skills": "---\nname: my-skill\ndescription: Mô tả một dòng (hiển thị khi Codex chọn skill)\n---\n\nNội dung - skill làm gì, khi nào dùng, ví dụ.\n",
"agents": "name = \"my-agent\"\ndescription = \"Khi nào gọi agent này\"\n\ninstructions = \"\"\"\nSystem prompt cho agent.\n\"\"\"\n",
"rules": "# Rules\n\nCodex tuân theo các quy tắc này ở phạm vi này.\n",
"memory": "# Bộ nhớ dự án\n\nCodex đọc tệp này khi bắt đầu phiên làm việc.\n",
"config": "# config.toml\n"
}
},
"memory": {
"missing": "Không tìm thấy AGENTS.md."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"config": {
"missing": "Không tìm thấy config.toml."
},
"mcp": {
"addTitle": "Thêm máy chủ MCP",
"nameLabel": "Tên",
"modeCommand": "Lệnh",
"modeUrl": "URL",
"commandLabel": "Lệnh",
"argsLabel": "Tham số (cách nhau bằng dấu cách)",
"urlLabel": "URL",
"bearerLabel": "Biến môi trường bearer token (tùy chọn)",
"addButton": "Thêm",
"removeButton": "Gỡ bỏ",
"added": "Đã thêm {{name}}.",
"removed": "Đã gỡ bỏ {{name}}."
},
"plugins": {
"installTitle": "Cài đặt plugin",
"installPlaceholder": "tên hoặc tên@marketplace",
"installButton": "Thêm",
"removeButton": "Gỡ bỏ",
"installed": "Đã cài đặt {{name}}.",
"removed": "Đã gỡ bỏ {{name}}.",
"noPlugins": "Chưa cài đặt plugin nào.",
"marketplacesTitle": "Marketplace",
"addMarketplaceTitle": "Thêm marketplace",
"addMarketplacePlaceholder": "https://github.com/owner/repo",
"addMarketplaceButton": "Thêm",
"marketplaceAdded": "Đã thêm marketplace.",
"marketplaceRemoved": "Đã gỡ bỏ marketplace {{name}}."
},
"manage": {
"working": "Đang xử lý…",
"noIdentifier": "Mục này không có định danh ổn định - hãy gỡ trực tiếp từ CLI."
},
"cli": {
"unavailable": "CLI codex không có sẵn trên máy này ({{message}}) - cài đặt hoặc thêm vào PATH để quản lý mục này."
},
"backups": {
"openButton": "Bản sao lưu",
"title": "Bản sao lưu",
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí CLI quét, nên không xuất hiện như mục thừa trong CLI.",
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
"restoreHint": "Khôi phục bằng cách đổi tên bản sao về vị trí cũ:"
},
"snippet": {
"copy": "Sao chép",
"copied": "Đã sao chép"
}
}
+8 -7
View File
@@ -7,16 +7,17 @@
"goBack": "Quay lại"
},
"notifications": {
"awaitingInput": "Claude cần bạn xác nhận",
"defaultBody": "Thông báo",
"defaultTitle": "Claude Code",
"finishedResponding": "Claude đã phản hồi xong",
"newSession": "Phiên mới",
"readyForInput": "Sẵn sàng nhận đầu vào",
"sessionClosed": "Phiên đã đóng",
"sessionCompleted": "Phiên đã hoàn tất",
"sessionDefault": "Phiên ",
"sessionError": "Lỗi phiên",
"subagentSpawned": "Đã tạo Subagent",
"finishedResponding": "Claude đã phản hồi xong",
"readyForInput": "Sẵn sàng nhận đầu vào",
"sessionCompleted": "Phiên đã hoàn tất",
"sessionClosed": "Phiên đã đóng",
"defaultTitle": "Claude Code",
"defaultBody": "Thông báo"
"subagentSpawned": "Đã tạo Subagent"
},
"toolLegend": {
"read": "Đọc",
+140 -34
View File
@@ -2,7 +2,6 @@
"action.clear": "Dọn trạng thái",
"action.forget": "Xóa lane",
"action.purge": "Xóa lịch sử",
"action.remove": "Xóa làn + worktree",
"action.reset": "Đặt lại worktree",
"action.start": "Bắt đầu",
"action.stop": "Dừng",
@@ -17,42 +16,105 @@
"actions.syncCheckClean": "DEV_DELTA: {{count}} file, trùng: {{overlap}}",
"actions.syncCheckCollision": "Trùng migration: {{file}} → đổi tên thành {{suggestion}}",
"actions.tools": "Công cụ",
"actions.writeDocs": "Tạo/cập nhật file readme",
"actions.writeDocsHint": "Mở terminal của lane này và chạy skill /sg-all:sg-md-writing: tạo CLAUDE.md/AGENTS.md nếu chưa có, hoặc cập nhật ngắn gọn nếu đã có.",
"actions.writeDocsResult": "Đã chạy — xem terminal của lane này.",
"add": "Thêm lane",
"addLane": "Tạo lane từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
"addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.",
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
"addLaneBranchExistingLabel": "Nhánh cần checkout",
"addLaneBranchHintAuto": "Để trống để dùng tên mặc định hiển thị ở trên.",
"addLaneBranchLabel": "Tên nhánh mới",
"addLaneBranchModeCurrent": "Nhánh hiện tại",
"addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.",
"addLaneBranchModeExisting": "Nhánh có sẵn",
"addLaneBranchModeNew": "Nhánh mới",
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
"addLanePipelineHint": "Bản đồ lane này sẽ hiện những stage nào. Chọn đúng cái mà skill điều khiển nó khai báo — sau này không màn hình nào đổi được.",
"addLanePipelineLabel": "Mẫu pipeline",
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục",
"addLaneRouterCustom": "AI Router",
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
"addLaneRouterLabel": "Endpoint model",
"addLaneRouterLoading": "Đang tải danh sách router…",
"addLaneRouterNative": "Native",
"addLaneRouterProfileLabel": "Profile router",
"addLaneSetupAgents": "Agent",
"addLaneSetupClose": "Đóng",
"addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile",
"addLaneSetupRouter": "Endpoint model",
"addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề",
"addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
"browse.button": "Duyệt",
"browse.empty": "Thư mục này trống.",
"browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree",
"addLaneTitlePlaceholder": "Không bắt buộc",
"autoStage": "tự động: {{stage}}",
"cardId": "Làn đường {{id}}",
"changes.back": "Tất cả file",
"changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
"changes.collapse": "Thu gọn bảng thay đổi",
"changes.collapseAll": "Thu gọn tất cả file",
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
"changes.collapseContext": "Bớt dòng xung quanh",
"changes.collapseFile": "Thu gọn {{path}}",
"changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage",
"changes.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
"changes.empty.branch": "Nhánh này không khác nhánh gốc.",
"changes.empty.commit": "Commit này không đổi file nào.",
"changes.empty.work": "Không có gì chưa commit.",
"changes.expand": "Mở bảng thay đổi",
"changes.expandAll": "Mở tất cả file",
"changes.expandAllFolders": "Mở tất cả thư mục",
"changes.expandContext": "Hiện thêm dòng xung quanh",
"changes.expandFile": "Mở {{path}}",
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
"changes.layout.split": "Hai cột",
"changes.layout.unified": "Một cột",
"changes.loading": "Đang đọc working copy…",
"changes.mode.browse": "Tệp",
"changes.mode.history": "Lịch sử",
"changes.mode.overview": "Tổng quan",
"changes.mode.review": "Xem toàn bộ",
"changes.noCommits": "Chưa có commit nào.",
"changes.noHunks": "Không có thay đổi nội dung.",
"changes.noMatches": "Không có file nào khớp.",
"changes.openFile": "Mở {{path}}",
"changes.quickOpen": "Tìm file theo đường dẫn…",
"changes.range.branch": "So nhánh gốc",
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của lane này thêm vào so với nhánh gốc",
"changes.range.work": "Chưa commit",
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
"changes.refresh": "Tải lại",
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
"changes.size.compact": "Hẹp",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của lane này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index",
"changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
@@ -76,34 +138,84 @@
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.",
"destructive.loading": "Đang tải trạng thái hiện tại…",
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.",
"destructive.notice.adopted": "Làn đường này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
"destructive.notice.missing": "Thư mục làn đường đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển và dọn mục worktree cũ trong Git; không tệp nào bị xóa khỏi đĩa.",
"destructive.notice.unreadable": "Không thể đọc thư mục làn đường như một Git worktree. Hệ thống sẽ thử xóa; nếu Git tự chối, mục ghi worktree của nó sẽ được dọn trực tiếp thay thế. Trong mọi trường hợp, thư mục vẫn không bị thay đổi.",
"destructive.notice.adopted": "Lane này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
"destructive.notice.missing": "Thư mục lane đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển; không tệp nào trên đĩa và không bản ghi Git nào bị đụng tới.",
"destructive.notice.unreadable": "Không thể đọc thư mục lane như một Git worktree. Điều đó không ảnh hưởng gì: chỉ bản ghi trên bảng điều khiển bị xóa, thư mục không bao giờ bị đụng tới.",
"destructive.purge.confirm": "Xóa lịch sử",
"destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.",
"destructive.purge.title": "Xóa lịch sử làn đường?",
"destructive.remove.confirm": "Xóa làn đường",
"destructive.remove.message": "Hãy xác nhận các số liệu hiện tại trước khi thực hiện thao tác này.",
"destructive.remove.title": "Xóa làn đường?",
"destructive.remove.confirm": "Xóa bản ghi lane",
"destructive.remove.message": "Chỉ bản ghi của lane trên bảng điều khiển bị xóa. Working tree, nhánh và cơ sở dữ liệu của lane được giữ nguyên — bạn tự xóa nếu thực sự muốn bỏ chúng.",
"destructive.remove.title": "Xóa bản ghi lane?",
"destructive.reset.confirm": "Đặt lại worktree",
"destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.",
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
"destructive.reset.title": "Đặt lại worktree được quản lý?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
"empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
"empty.title": "Chưa có lane nào",
"features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"git.filesHeading_one": "{{count}} tệp thay đổi",
"git.filesHeading_other": "{{count}} tệp thay đổi",
"git.filesMore_one": "… và {{count}} tệp nữa",
"git.filesMore_other": "… và {{count}} tệp nữa",
"git.noDelta": "mới",
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
"gitBar.branchCreate": "Tạo",
"gitBar.branchCreateHint": "Tạo nhánh này mà không chuyển sang",
"gitBar.branchDelete": "Xoá",
"gitBar.branchDeleteHint": "Xoá nhánh đang chọn (git từ chối nếu còn commit chưa merge)",
"gitBar.branchNewPlaceholder": "Tên nhánh mới…",
"gitBar.cancel": "Huỷ",
"gitBar.commit": "Commit",
"gitBar.commitAll_one": "Commit {{count}} file",
"gitBar.commitAll_other": "Commit {{count}} file",
"gitBar.commitHint": "Stage tất cả rồi commit",
"gitBar.commitNothing": "Không có gì để commit",
"gitBar.commitPlaceholder": "Nội dung commit",
"gitBar.commitStaged": "Commit phần đã stage",
"gitBar.commitStagedHint": "Chỉ commit những file đã stage ở thẻ Changes",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.inSync": "đã đồng bộ",
"gitBar.newLane": "Tạo lane mới",
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
"gitBar.noUpstream": "chưa push",
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
"gitBar.push": "Push",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.switchCurrent": "hiện tại",
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…",
"kind.adopted": "đã nhận",
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
"proof.ticketReport": "Báo cáo nhiệm vụ",
"renameLabel": "Đổi tên làn đường này",
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
"router.applied": "Đã áp dụng.",
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
"router.label": "Model",
"router.restart": "Khởi động lại",
"router.restarted": "Đã khởi động lại phiên với router mới.",
"runtime.boot": "▶ chạy",
"runtime.busy.down": "đang dừng…",
"runtime.busy.up": "đang khởi động…",
@@ -114,23 +226,17 @@
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
"runtime.stop": "■ dừng",
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
"integrations.ci_wait": "CI wait",
"integrations.dev_qc": "dev QC",
"integrations.tracker": "tracker",
"stageUndeclared": "chưa khai báo",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneCount": "{{count}} ô",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.pickLane": "Chọn lane",
"status.failed": "thất bại",
"status.idle": "rảnh",
"status.provisioning": "đang khởi tạo",
"status.running": "đang chạy",
"features.live": "Phiên bản trực tiếp",
"features.archived": "đã lưu trữ",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"proof.ticketReport": "Báo cáo nhiệm vụ",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.paneCount": "{{count}} ô",
"splitView.pickLane": "Chọn lane",
"statusDead": "ĐÃ CHẾT",
"title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree"
}
+5 -3
View File
@@ -3,8 +3,8 @@
"agentBoard": "Bảng Kanban",
"analytics": "Phân tích",
"avg": "TB",
"brand": "Bảng điều khiển Agent",
"brandSub": "Giám sát Claude Code",
"brand": "AgentHub",
"brandSub": "SmartGift Solution",
"ccConfig": "Cấu hình Claude",
"checkFailed": "Kiểm tra cập nhật thất bại",
"checkForUpdates": "Kiểm tra cập nhật",
@@ -65,5 +65,7 @@
"website": "Website",
"workflows": "Quy trình",
"workspace": "Không gian làm việc",
"wsEndpoint": "WebSocket"
"wsEndpoint": "WebSocket",
"releaseAvailable": "Có {{version}}",
"devBuild": "Bản dev · trước {{count}} commit"
}
+6 -3
View File
@@ -33,6 +33,7 @@
},
"errors": {
"attachFailed": "Không gắn được vào run: {{message}}",
"attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
"cwdRequired": "Cần thư mục làm việc để bắt đầu run",
"killFailed": "Không dừng được: {{message}}",
"laneCreateFailed": "Không thể tạo làn đường: {{message}}",
@@ -60,6 +61,7 @@
"cwdGroups": {
"dashboard": "Dashboard server",
"home": "Home",
"repo": "Repo nguồn",
"recent": "Gần đây - Claude Code đã dùng"
},
"cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.",
@@ -70,7 +72,8 @@
"modelCustom": "Tuỳ chỉnh…",
"modelCustomPlaceholder": "model id (vd: claude-opus-5)",
"modelInheritLabel": "Kế thừa từ settings",
"permissionAcceptEdits": "acceptEdits (khuyên dùng)",
"permissionAcceptEdits": "acceptEdits (sửa file không hỏi)",
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Permission mode",
@@ -94,7 +97,7 @@
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
},
"limitations": {
"dismiss": "Đã rõ"
@@ -152,7 +155,7 @@
"permissionMode": "Mode",
"session": "Session"
},
"subtitle": "Khởi chạy một phiên {{tool}} ngay trong dashboard. Stream output trực tiếp, hội thoại đa lượt, và chia sẻ cùng pipeline hooks/analytics với phiên ở terminal.",
"subtitle": "Stream trực tiếp, hội thoại đa lượt, cùng hooks và analytics như ở terminal.",
"title": "Chạy {{tool}}",
"tokens": {
"cacheRead": "Cache hit",
+56 -11
View File
@@ -71,21 +71,27 @@
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
},
"notifications": {
"title": "Thông báo",
"awaitingInput": "Claude cần xác nhận",
"blocked": "Đã chặn quyền",
"description": "Thông báo trình duyệt cho các sự kiện quan trọng. Cần cấp quyền.",
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên",
"enable": "Bật thông báo trình duyệt",
"granted": "Đã cấp quyền",
"blocked": "Đã chặn quyền",
"required": "Cần cấp quyền",
"notifyWhen": "Thông báo cho tôi khi...",
"newSession": "Phiên mới bắt đầu",
"notifyWhen": "Thông báo cho tôi khi...",
"required": "Cần cấp quyền",
"sendTest": "Gửi thông báo thử",
"sessionComplete": "Phiên hoàn tất",
"sessionError": "Phiên gặp lỗi",
"subagentSpawned": "Subagent được tạo",
"testTitle": "Agent Monitor",
"testBlocked": "Trình duyệt chưa cấp quyền thông báo.",
"testBody": "Thông báo đang hoạt động!",
"sendTest": "Gửi thông báo thử",
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên"
"testFailed": "Không hiển thị được thông báo nào.",
"testLocal": "Trình duyệt chưa đăng ký push nên thông báo được hiện tại chỗ. Web Push cần origin bảo mật (localhost hoặc HTTPS) và service worker đã đăng ký.",
"testSent": "Đã gửi qua push — kiểm tra trung tâm thông báo.",
"testTitle": "Agent Monitor",
"title": "Thông báo",
"turnComplete": "Claude trả lời xong"
},
"import": {
"title": "Nhập lịch sử",
@@ -446,9 +452,48 @@
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"cursor": "Cursor",
"soonBadge": "sắp có",
"comingSoon": "{{tool}} chưa được tích hợp",
"comingSoonDesc": "Cấu hình của công cụ này sẽ hiện ở đây khi có hỗ trợ."
"aiRouter": "AI Router"
},
"aiRouter": {
"title": "AI Router",
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo lane.",
"add": "Thêm profile",
"edit": "Sửa",
"remove": "Xoá",
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
"colLabel": "Profile",
"colBaseUrl": "Endpoint",
"colModel": "Model",
"colKey": "API key",
"keySet": "Đã lưu",
"keyMissing": "Chưa có",
"fieldLabel": "Tên",
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo lane. Gõ tự do.",
"fieldBaseUrl": "Base URL",
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
"fieldApiKey": "API key",
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
"perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": {
"opus": "Opus",
"sonnet": "Sonnet",
"haiku": "Haiku",
"smallFast": "Nhỏ / nhanh",
"subagent": "Subagent"
},
"save": "Lưu",
"cancel": "Huỷ",
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi lane đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
"loading": "Đang tải danh sách profile…",
"lanesFailed": "Đã lưu, nhưng {{count}} lane không cập nhật được và vẫn đang giữ endpoint cũ: {{lanes}}",
"test": "Kiểm tra",
"testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}"
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"tagline": "Mọi điều agent của bạn làm đều không ẩn trong bóng tối.",
"sub1": "Khả năng quan sát thời gian thực cho mọi phiên Claude Code.",
"sub2": "Token, công cụ, chi phí và workflow, tất cả trực tiếp.",
"brand": "Giám sát Claude Code",
"brand": "SmartGift Solution",
"taglines": [
"Mọi điều agent của bạn làm đều không ẩn trong bóng tối.",
"Thấy rõ từng bước đi của agent.",

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