Compare commits

...

14 Commits

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

Client:
- Changes panel: no stale answer from a previous lane or an older quick-open
  query repaints the panel; discard disarms on lane switch; Enter on a file
  path opens it instead of folding; bounded tree expand-all.
- Large diffs (> 500 rows) render only the visible rows, with stable width
  and in-sync split columns; mirrored split scroll is clamped.
- Git bar: no "in sync" without counts, a clear no-remote message, branch list
  re-read after a switch, and no previous-lane facts after a lane switch.
- Tooltips no longer flicker or close on unrelated scrolls.
2026-09-24 10:30:25 +07:00
nntrivi2001 6153d50fa8 chore(release): bump version to 1.8.5 2026-09-24 10:30:25 +07:00
nntrivi2001 562f43fd88 fix(ui): keep the sidebar's EN/VI buttons at full width beside the three theme buttons
The language row was a 4-column grid holding two buttons inside a flex-1 box. Once the theme row gained a third (System) button, that box narrowed and each language button fell below the width of its own label, so EN/VI sat off-centre and overflowed. One column per language, centred text.
2026-09-24 10:28:51 +07:00
nntrivi2001 8ea40260ad feat(ui): terminal, charts and low-contrast text follow the selected theme
- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
2026-09-24 09:50:44 +07:00
nntrivi2001 6353e88c9e chore(release): bump version to 1.8.4 2026-09-24 09:46:52 +07:00
nntrivi2001 6cf8a82889 feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and
  a named theme remembered per mode — dark: Default, Darcula, Apple Dark;
  light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a
  CSS-variable override block keyed by `data-theme` on <html>; the sidebar
  quick toggle gains System and swaps between the two remembered themes.
- An inline boot script in index.html applies the stored mode and theme
  before first paint, so a reload no longer flashes the default colours.
- Diff +/- rows are easier to tell apart on every theme: a /20 status tint,
  a 3px status bar and a bold coloured marker; the changed-span emphasis no
  longer vanishes on light themes.
- #42: the Add-lane modal (Repo mode / "Current branch") asks
  POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside
  an existing lane resolved to that parent lane with created:false, so the
  modal closed and no new card appeared. When the folder really already has a
  lane, the modal now says which one instead of closing.

Closes #42
2026-09-24 09:44:42 +07:00
nntrivi2001 2ea2184372 chore(release): bump version to 1.8.3 2026-09-23 16:46:40 +07:00
nntrivi2001 aff863c69f fix(plugins): retry claude plugin install|update without -y on an old CLI
An older Claude Code has no -y on `plugin install|update` and exits with
"error: unknown option '-y'", so the dashboard's Update now chain failed at
its plugin_update step (and so did Update all, the plugin buttons and the
superpowers auto-install). Those versions also predate the confirmation
prompt -y answers, so every wrapper now retries the same argv without it
when, and only when, the CLI rejects the flag. /agenthub-update, which the
failure message points users to, falls back the same way.
2026-09-23 16:46:20 +07:00
nntrivi2001 764b967856 fix(dashboard): measure V8 heap against its limit, not its current size
The Health Score's heap component divided heapUsed by heapTotal, which is
only what V8 has grown the heap to so far, so a healthy server read 70-90%
and dragged the score down. GET /api/settings/info now also reports
server.memory.heapLimit (v8 heap_size_limit, the ceiling the process dies
at); the V8 Heap bar and the Health Score divide by it, falling back to
heapTotal against an older server.
2026-09-23 16:46:00 +07:00
99 changed files with 7350 additions and 1756 deletions
+4 -4
View File
@@ -369,7 +369,7 @@ graph TD
| `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/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff; `discard-hunk` needs `confirm:true`), `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 |
@@ -470,7 +470,7 @@ 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.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
@@ -606,7 +606,7 @@ graph LR
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` 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 |
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
| `/*` | NotFound | None (static 404 page) |
### Activity Feed Interaction Model
@@ -2397,7 +2397,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light toggle re-themes the app with one class flip rather than per-component `dark:` variants |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
| **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) |
| **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues |
+3 -3
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -64,6 +64,10 @@ Internal build — all rights reserved.
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark; light:
Default, Apple Light, IntelliJ Light, Canvas. Each mode remembers its own
theme, so the sidebar's quick toggle swaps between the two. Per-browser only.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
+2 -2
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
@@ -208,7 +208,7 @@ client/
│ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
│ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── App.tsx # Root component + router setup
+18
View File
@@ -1,6 +1,24 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<!-- Apply the saved appearance before first paint (same keys as
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
<script>
try {
var m = localStorage.getItem("theme");
var d =
m === "light"
? false
: m === "system"
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
: true;
var r = document.documentElement;
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
r.classList.toggle("dark", d);
r.setAttribute("data-mode", d ? "dark" : "light");
if (t && t !== "default") r.setAttribute("data-theme", t);
} catch (e) {}
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
+3 -1
View File
@@ -182,7 +182,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<div
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
isMain
? "bg-accent/15 text-accent"
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
}`}
>
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
+1 -1
View File
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
const color = value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
return (
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
+4 -4
View File
@@ -625,7 +625,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-400"
color="text-blue-700 dark:text-blue-400"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
@@ -672,12 +672,12 @@ export function ImportHistory() {
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events}
color="text-violet-300"
color="text-violet-700 dark:text-violet-300"
/>
<ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
value={backupResult.model_pricing}
color="text-cyan-300"
color="text-cyan-700 dark:text-cyan-300"
/>
</div>
<p className="text-[11px] text-fg-muted">
@@ -737,7 +737,7 @@ function ModeButton({
>
<div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-400" : "text-fg-secondary"
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
}`}
>
{icon}
+12 -12
View File
@@ -95,21 +95,21 @@ function StatTile({
}) {
const palette = {
default: "border-surface-3 bg-surface-2 text-fg-secondary",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
}[tone];
const iconTone = {
default: "text-fg-muted",
violet: "text-violet-400",
violet: "text-violet-700 dark:text-violet-400",
emerald: "text-status-success",
amber: "text-status-warning",
rose: "text-rose-400",
cyan: "text-cyan-400",
rose: "text-rose-700 dark:text-rose-400",
cyan: "text-cyan-700 dark:text-cyan-400",
blue: "text-blue-500",
}[tone];
@@ -359,7 +359,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-violet-400" />
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
Top tools
</h3>
<span className="text-[10px] text-fg-muted font-mono">
@@ -418,7 +418,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
Subagents
</h3>
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
@@ -508,14 +508,14 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
label: "Cache read",
value: tokens.cache_read_tokens,
cls: "bg-sky-500",
text: "text-sky-300",
text: "text-sky-700 dark:text-sky-300",
},
{
key: "cache_write",
label: "Cache write",
value: tokens.cache_write_tokens,
cls: "bg-violet-500",
text: "text-violet-300",
text: "text-violet-700 dark:text-violet-300",
},
{
key: "input",
@@ -529,7 +529,7 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
label: "Output",
value: tokens.output_tokens,
cls: "bg-orange-500",
text: "text-orange-300",
text: "text-orange-700 dark:text-orange-300",
},
];
+13 -8
View File
@@ -71,6 +71,7 @@ import {
ChevronUp,
ChevronDown,
Sun,
Monitor,
Moon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -177,7 +178,7 @@ interface SidebarProps {
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, setTheme, toggleTheme } = useTheme();
const { theme, mode, setMode, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle).
@@ -519,7 +520,9 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:language")}
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{/* One column per language: a wider grid squeezed each button
below the width of its own label once Theme grew a third. */}
<div className="mt-2 grid grid-cols-2 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
@@ -529,7 +532,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
className={`rounded-md px-1 py-1.5 text-center text-[11px] font-semibold transition-colors ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
@@ -545,13 +548,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:theme")}
</p>
<div className="mt-2 grid grid-cols-2 gap-1">
{(["dark", "light"] as const).map((option) => {
const active = option === theme;
<div className="mt-2 grid grid-cols-3 gap-1">
{(["dark", "light", "system"] as const).map((option) => {
const active = option === mode;
return (
<button
key={option}
onClick={() => setTheme(option)}
onClick={() => setMode(option)}
aria-pressed={active}
aria-label={t(`nav:themeNames.${option}`)}
title={t(`nav:themeNames.${option}`)}
@@ -563,8 +566,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
>
{option === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
) : option === "light" ? (
<Sun className="w-3.5 h-3.5" />
) : (
<Monitor className="w-3.5 h-3.5" />
)}
</button>
);
+1 -1
View File
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
cfg.urgent
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90"
}`}
>
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
+89 -9
View File
@@ -9,10 +9,13 @@
* 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
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
* by removing the attribute for as long as the bubble is up, and restores it
* on the way out. Nothing else in the app has to know it exists, and a control
* keeps its native tooltip (and its accessible name) when JS has not run.
* While the title is parked, a control with no other name (icon-only) carries
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
* bubble goes away with its control if that is removed from the page.
*
* ## The look
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
@@ -39,8 +42,10 @@ 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])";
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
* `[data-tip]` matches the control whose title is parked, so moving between
* its own children still resolves to it instead of re-opening the bubble. */
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
type Anchored = { text: string; rect: DOMRect };
type Placed = { left: number; top: number; arrow: number; above: boolean };
@@ -58,16 +63,29 @@ export function GlobalTooltip() {
// that unmounts it, Escape).
const parked = useRef<HTMLElement | null>(null);
const timer = useRef<number | undefined>(undefined);
// The element whose delay is running, so re-entering it does not restart it.
const pending = useRef<HTMLElement | null>(null);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
pending.current = null;
const el = parked.current;
if (el) {
// Drop the instance shim `open` installed; the prototype method is back.
delete (el as { removeAttribute?: unknown }).removeAttribute;
const text = el.dataset.tip;
if (text !== undefined) {
el.setAttribute("title", text);
// React re-set `title` while it was parked (a poll flipped the hint):
// that value is current, the parked copy is stale. React never
// re-applies an unchanged prop, so writing the old one back would win
// for good.
if (!el.hasAttribute("title")) el.setAttribute("title", text);
delete el.dataset.tip;
}
if (el.dataset.tipAria !== undefined) {
el.removeAttribute("aria-label");
delete el.dataset.tipAria;
}
parked.current = null;
}
setTip(null);
@@ -79,7 +97,9 @@ export function GlobalTooltip() {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
pending.current = el;
timer.current = window.setTimeout(() => {
pending.current = null;
// Re-read at fire time: the control may have been re-rendered, removed
// or had its title changed while the delay was running.
if (!el.isConnected) return;
@@ -87,6 +107,29 @@ export function GlobalTooltip() {
if (!current) return;
el.dataset.tip = current;
el.removeAttribute("title");
// React removing `title` while it is parked calls removeAttribute on an
// attribute that is already gone: no MutationRecord, nothing to see —
// and `restore` would write the stale text back for good. So, on THIS
// element and only while parked, that call is taken as React's intent:
// the parked copy is dropped and the bubble hides. (React DOM calls
// `node.removeAttribute`, the instance method; `restore` deletes the
// shim.) No global prototype is touched.
el.removeAttribute = (name: string) => {
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
delete el.dataset.tip;
restore();
}
Element.prototype.removeAttribute.call(el, name);
};
// The title was an icon-only control's ONLY name; keep it named.
if (
!el.hasAttribute("aria-label") &&
!el.hasAttribute("aria-labelledby") &&
!el.textContent?.trim()
) {
el.setAttribute("aria-label", current);
el.dataset.tipAria = "";
}
parked.current = el;
setTip({ text: current, rect: el.getBoundingClientRect() });
}, delay);
@@ -101,7 +144,10 @@ export function GlobalTooltip() {
};
const onOver = (e: PointerEvent) => {
const el = target(e);
if (el) open(el, SHOW_DELAY_MS);
// Already showing or already counting down for this control: a pointer
// crossing its children must neither hide nor restart it.
if (!el || el === parked.current || el === pending.current) return;
open(el, SHOW_DELAY_MS);
};
const onOut = (e: PointerEvent) => {
// Moving between children of the same control must not flicker.
@@ -112,9 +158,14 @@ export function GlobalTooltip() {
};
// Keyboard users get the same tooltip, immediately — a focus ring that
// reveals nothing is the whole reason icon-only buttons are hard to learn.
// Only KEYBOARD focus, though: a click focuses too, and without the
// `:focus-visible` check it reopened the bubble the pointerdown just
// closed, with no delay. Hover keeps its own path.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (el) open(el, 0);
if (!el || el === parked.current) return;
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
open(el, 0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") restore();
@@ -126,9 +177,17 @@ export function GlobalTooltip() {
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);
// A bubble anchored to a rect is wrong the moment its anchor moves — but
// only a scroll of the document or of a box containing the anchor moves
// it. A log tailing in another panel must not close the bubble.
const onScroll = (e: Event) => {
const anchor = parked.current || pending.current;
const t = e.target;
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", restore);
window.addEventListener("blur", restore);
return () => {
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
@@ -136,12 +195,33 @@ export function GlobalTooltip() {
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", restore);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", restore, true);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", restore);
window.removeEventListener("blur", restore);
restore();
};
}, [open, restore]);
// A control unmounted under a still cursor fires no pointerout: without this
// its bubble stays up over whatever replaced it. The same goes for a title
// re-rendered under it — the bubble would keep saying the old text, so it
// hides and `restore` keeps the new attribute.
useEffect(() => {
if (!tip) return;
const watch = new MutationObserver((records) => {
const el = parked.current;
if (!el) return;
if (
!el.isConnected ||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
)
restore();
});
watch.observe(document.body, { childList: true, subtree: true });
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
return () => watch.disconnect();
}, [tip, restore]);
// Placed after the first paint, when the bubble's real size is known —
// measuring it is the only way to flip and clamp without guessing.
useEffect(() => {
@@ -0,0 +1,59 @@
/**
* @file Sidebar.theme.test.tsx
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
function renderSidebar(collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
</MemoryRouter>
);
}
describe("Sidebar theme controls", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
renderSidebar();
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking System sets the mode and presses only System", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "System" }));
expect(localStorage.getItem("theme")).toBe("system");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking Light applies light mode to <html>", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "Light" }));
expect(localStorage.getItem("theme")).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
const user = userEvent.setup();
localStorage.setItem("theme", "light");
renderSidebar(true);
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
});
});
@@ -10,6 +10,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
@@ -24,6 +25,24 @@ function setup(extra?: React.ReactNode) {
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
// jsdom's :focus-visible is a heuristic over the previous events and leaks
// between tests, so the tests say which kind of focus they mean: a spy answers
// `:focus-visible` from this flag and passes every other selector through.
let keyboardFocus = false;
const realMatches = Element.prototype.matches;
beforeEach(() => {
keyboardFocus = false;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
return sel === ":focus-visible"
? keyboardFocus && document.activeElement === this
: realMatches.call(this, sel);
});
});
afterEach(() => vi.restoreAllMocks());
const tabTo = (el: HTMLElement) => {
keyboardFocus = true;
act(() => el.focus());
};
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
@@ -63,7 +82,7 @@ describe("GlobalTooltip", () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
fireEvent.focusIn(button);
tabTo(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
@@ -105,4 +124,165 @@ describe("GlobalTooltip", () => {
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
it("keeps an icon-only control's accessible name while its title is parked", async () => {
setup(
<button title="Stage this file">
<svg aria-hidden="true" />
</button>
);
const icon = screen.getByRole("button", { name: "Stage this file" });
tabTo(icon);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
expect(icon).not.toHaveAttribute("title");
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
fireEvent.keyDown(document, { key: "Escape" });
// Put back exactly as found: no aria-label left behind.
expect(icon).toHaveAttribute("title", "Stage this file");
expect(icon).not.toHaveAttribute("aria-label");
});
it("does not flicker while the pointer crosses the control's own children", async () => {
setup(
<button title="Two parts">
<span data-testid="part-a">a</span>
<span data-testid="part-b">b</span>
</button>
);
const a = screen.getByTestId("part-a");
const b = screen.getByTestId("part-b");
hover(a);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
// Still up, with no fresh delay.
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
});
it("hides when its control is removed from the page", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Going away">x</button>
</>
);
hover(screen.getByRole("button", { name: "x" }));
await settle();
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
rerender(
<>
<GlobalTooltip />
</>
);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// A poll flips the hint while the pointer rests on the control.
rerender(
<>
<GlobalTooltip />
<button title="Up to date">pull</button>
</>
);
await settle();
// The bubble must not keep advertising the old text.
expect(screen.queryByText("Pull 2 commits")).toBeNull();
unhover(button);
expect(button).toHaveAttribute("title", "Up to date");
});
it("leaves no title behind when React removed it while the bubble was up", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// React "removes" an attribute that is already parked: no MutationRecord.
rerender(
<>
<GlobalTooltip />
<button>pull</button>
</>
);
await settle();
unhover(button);
expect(button).not.toHaveAttribute("title");
expect(button).not.toHaveAttribute("data-tip");
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setup(<button title="Second control">second</button>);
// A click is pointer focus: :focus-visible does not match.
const button = screen.getByRole("button", { name: "refresh" });
await user.click(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.queryByTestId("global-tooltip")).toBeNull();
tabTo(screen.getByRole("button", { name: "second" }));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
});
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
render(
<>
<GlobalTooltip />
<div data-testid="scroller">
<button title="Inside the scroller">inside</button>
</div>
<div data-testid="elsewhere" />
</>
);
const button = screen.getByRole("button", { name: "inside" });
hover(button);
await settle();
fireEvent.scroll(screen.getByTestId("elsewhere"));
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
fireEvent.scroll(screen.getByTestId("scroller"));
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Inside the scroller");
});
it("hides when the document itself scrolls", async () => {
setup();
hover(screen.getByRole("button", { name: "refresh" }));
await settle();
fireEvent.scroll(document);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
});
+2 -2
View File
@@ -12,11 +12,11 @@ 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"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: scope === "project"
? "bg-status-success/10 text-status-success border-status-success/30"
: scope === "project-local"
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
? "bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30"
: "bg-surface-3 text-fg-secondary border-border";
const label =
scope === "project-local"
@@ -320,7 +320,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={linkM[2]!}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
</a>
@@ -337,7 +337,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={urlM[0]}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
>
{urlM[0]}
</a>
@@ -428,7 +428,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
);
}
return (
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
<ul
key={idx}
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
{renderListItem(item, `li${idx}-${i}`)}
@@ -82,25 +82,25 @@ const SENDER_STYLES: Record<
label: "User",
icon: User,
avatarRing:
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30",
accentBar: "before:bg-blue-600/40",
headerText: "text-blue-300",
headerText: "text-blue-700 dark:text-blue-300",
},
assistant: {
label: "Assistant",
icon: Bot,
avatarRing:
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30",
accentBar: "before:bg-violet-500/40",
headerText: "text-violet-200",
headerText: "text-violet-800 dark:text-violet-200",
},
orchestrator: {
label: "Main agent",
icon: Workflow,
avatarRing:
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30",
accentBar: "before:bg-teal-500/40",
headerText: "text-teal-200",
headerText: "text-teal-800 dark:text-teal-200",
},
system: {
label: "System",
@@ -169,7 +169,7 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
return (
<div className="flex items-center justify-center py-1">
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
<Pencil className="w-3 h-3 text-violet-700/70 dark:text-violet-300/70 flex-shrink-0" />
<span className="text-fg-muted">Renamed session →</span>
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
{timestamp && (
@@ -253,11 +253,13 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
<CollapsibleBlock
key={key}
text={seg.text}
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
icon={
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
}
title="Persisted output"
borderClass="border-violet-500/20"
bgClass="bg-violet-500/5"
textClass="text-violet-300/80"
textClass="text-violet-700/80 dark:text-violet-300/80"
/>
);
case "text": {
@@ -394,7 +396,9 @@ export function MessageList({ messages, loading }: MessageListProps) {
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span className="text-fg-muted">·</span>
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
<span className="text-orange-700/70 dark:text-orange-300/70">
↑ {fmt(msg.usage.output_tokens)}
</span>
</span>
)}
{msg.timestamp && (
@@ -413,11 +417,13 @@ export function MessageList({ messages, loading }: MessageListProps) {
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-cyan-400/60 flex-shrink-0" />}
icon={
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
}
title="Task Notification"
borderClass="border-cyan-500/20"
bgClass="bg-cyan-500/5"
textClass="text-cyan-400/80"
textClass="text-cyan-700/80 dark:text-cyan-400/80"
/>
);
}
@@ -156,7 +156,7 @@ function renderInput(toolUse: TranscriptContent) {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
<FileText className="w-3.5 h-3.5 text-violet-400" />
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<span className="font-mono">{obj.file_path}</span>
{obj.replace_all === true && (
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
@@ -178,7 +178,7 @@ function renderInput(toolUse: TranscriptContent) {
if (tool === "read" && typeof obj.file_path === "string") {
return (
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
<span className="font-mono break-all">{obj.file_path}</span>
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
@@ -198,7 +198,7 @@ function renderInput(toolUse: TranscriptContent) {
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Pattern
</span>
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
{obj.pattern}
</code>
</div>
+30 -30
View File
@@ -83,8 +83,8 @@ export interface ToolStyle {
const VIOLET: ToolStyle = {
Icon: Wrench,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
text: "text-violet-700 dark:text-violet-300",
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
};
@@ -92,99 +92,99 @@ const VIOLET: ToolStyle = {
const STYLES: Record<string, ToolStyle> = {
bash: {
Icon: Terminal,
text: "text-emerald-300",
chip: "bg-emerald-500/15 text-emerald-300",
text: "text-emerald-700 dark:text-emerald-300",
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
bar: "bg-emerald-500/60",
border: "border-emerald-500/20",
},
read: {
Icon: FileText,
text: "text-sky-300",
chip: "bg-sky-500/15 text-sky-300",
text: "text-sky-700 dark:text-sky-300",
chip: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
bar: "bg-sky-500/60",
border: "border-sky-500/20",
},
write: {
Icon: FilePlus2,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
text: "text-violet-700 dark:text-violet-300",
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
},
edit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
text: "text-amber-700 dark:text-amber-300",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
multiedit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
text: "text-amber-700 dark:text-amber-300",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
grep: {
Icon: Search,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
text: "text-cyan-700 dark:text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
glob: {
Icon: FolderTree,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
text: "text-cyan-700 dark:text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
webfetch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
text: "text-blue-700 dark:text-blue-300",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
websearch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
text: "text-blue-700 dark:text-blue-300",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
task: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
text: "text-pink-700 dark:text-pink-300",
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
agent: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
text: "text-pink-700 dark:text-pink-300",
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
todowrite: {
Icon: ListTodo,
text: "text-rose-300",
chip: "bg-rose-500/15 text-rose-300",
text: "text-rose-700 dark:text-rose-300",
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
bar: "bg-rose-500/60",
border: "border-rose-500/20",
},
schedulewakeup: {
Icon: Clock,
text: "text-orange-300",
chip: "bg-orange-500/15 text-orange-300",
text: "text-orange-700 dark:text-orange-300",
chip: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
bar: "bg-orange-500/60",
border: "border-orange-500/20",
},
skill: {
Icon: Sparkles,
text: "text-fuchsia-300",
chip: "bg-fuchsia-500/15 text-fuchsia-300",
text: "text-fuchsia-700 dark:text-fuchsia-300",
chip: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300",
bar: "bg-fuchsia-500/60",
border: "border-fuchsia-500/20",
},
@@ -289,7 +289,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
let newLine = hunk.newStart;
return (
<div>
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
<div className="px-3 py-1 text-[10px] text-cyan-700 dark:text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</div>
<table className="w-full border-collapse">
@@ -301,7 +301,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
const showNew = kind !== "remove";
const rowBg =
kind === "add"
? "bg-green-500/10 text-green-200"
? "bg-green-500/10 text-green-800 dark:text-green-200"
: kind === "remove"
? "bg-status-danger/10 text-status-danger"
: "text-fg-secondary";
@@ -372,7 +372,7 @@ function ValueCell({ value }: { value: unknown }) {
<span
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10"
}`}
>
@@ -445,7 +445,7 @@ export function MatchList({ matches }: { matches: GrepMatch[] }) {
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
{matches.map((m, i) => (
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
{m.file && <span className="text-cyan-300">{m.file}</span>}
{m.file && <span className="text-cyan-700 dark:text-cyan-300">{m.file}</span>}
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
</li>
+18 -18
View File
@@ -27,18 +27,6 @@ import { api } from "../../lib/api";
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
* lane record, or `null` on timeout. */
@@ -246,6 +234,8 @@ export function AddLaneModal({
cwd: repo,
title: name || undefined,
tool,
// THIS directory, not a lane on a parent of it (#42).
exact: true,
});
// Report the lane the INSTANT it exists server-side. Everything after
// this is post-creation setup that can fail on its own, and letting one
@@ -253,6 +243,18 @@ export function AddLaneModal({
// the list until some unrelated refetch happened to pick it up.
// Worktree mode below already works this way.
onAdded(result.lane);
// Nothing was created: closing now reads as success while no new card
// appears (#42). Say which lane already owns the folder instead.
if (!result.created) {
setError(
t("addLaneExists", {
id: result.lane.id,
title: result.lane.title || result.lane.cwd,
})
);
setBusy(false);
return;
}
// 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.
@@ -575,12 +577,10 @@ export function AddLaneModal({
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")
}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
@@ -99,7 +99,7 @@ export function FolderBrowseModal({
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
+15 -276
View File
@@ -1,5 +1,5 @@
/**
* @file One lane's card: branch/CI/PR facts, the "needs you"
* @file One lane's card: CI/PR facts, the "needs you"
* banner sourced from Claude Code's Notification hook, and (for managed
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
* DestructiveLaneModal. A dead lane (its driving session went silent while
@@ -16,74 +16,33 @@
* install / MCP sync / dev-sync check / create-update readme) and the
* remove-lane button live in Workspace's page header / pipeline-picker row
* instead of here, so they stay reachable and un-clipped regardless of this
* card's own scroll area.
* card's own scroll area. The working copy — branch, uncommitted counts, the
* git action bar — is not here either: it heads the Changes panel
* (`LaneGitHeader`), beside the diff it describes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEffect, useRef, useState } from "react";
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 { AiRouter } from "../../lib/api";
import type { Lane, LaneGitFacts, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types";
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
* each call is 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;
/** How often a mounted card re-probes its stack. Faster than the git refresh
* because a stack dying is exactly what the user needs to see, and slower than
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
* git refresh because a stack dying is exactly what the user needs to see, and slower than
* the lane poll because each call opens a socket per declared port. */
const RUNTIME_REFRESH_MS = 10_000;
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
* as the git facts (slow, since lock state changes infrequently). */
* as the Changes panel's git facts (slow, since lock state changes infrequently). */
const LOCKS_REFRESH_MS = 30_000;
/**
* The lane's own working copy, fetched per card rather than folded into the
* polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders as a card without a git row.
*/
function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => 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;
const read = () => {
api.lanes
.git(laneId)
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
.catch(() => {
// Silent: a card that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip.
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
/**
* The lane's own application stack. Same shape and same silence as the git
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
* is the common case and not an error worth a banner.
*
* `bump` lets an up/down action re-read immediately instead of waiting out the
@@ -191,7 +150,7 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
if (alive) setRouters(r.items);
})
.catch(() => {
// Silent, same contract as the git/runtime pollers above: a card that
// Silent, same contract as the runtime poller above: a card that
// cannot list routers simply shows no router row.
if (alive) setRouters([]);
});
@@ -473,147 +432,11 @@ 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;
@@ -623,31 +446,12 @@ 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 [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);
@@ -676,7 +480,9 @@ export default function LaneCard({
<>
<div
data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
// Every row below is conditional: a lane with nothing to report
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
>
{(lane.ci_status || lane.tool === "codex") && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
@@ -773,73 +579,6 @@ export default function LaneCard({
</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) && (
<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 })}
</button>
)}
</div>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div>
)}
{git?.available && (
<LaneGitBar
laneId={lane.id}
git={git}
onChanged={refreshGit}
/* A sibling worktree forks from the lane's SOURCE repo when it
has one (this lane is itself a worktree of it); an adopted lane
is its own repo. */
onNewLane={
onNewLaneFromRepo
? (branch) => onNewLaneFromRepo(lane.source_repo || lane.cwd, branch)
: undefined
}
/>
)}
{/* The lane's own recorded branch, shown only when git could not be
read — otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && (
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
⑂ {lane.branch}
</div>
)}
{childWorktrees && childWorktrees.length > 0 && (
<div
data-testid="lane-child-worktrees"
@@ -854,7 +593,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
+391 -145
View File
@@ -41,15 +41,18 @@ import {
RefreshCw,
Rows3,
Trash2,
Undo2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneDiffHunk,
LaneTreeEntry,
} from "../../lib/types";
@@ -62,7 +65,7 @@ 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. */
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
function statusClass(status: string, untracked: boolean) {
if (untracked) return "text-status-success";
const code = status.trim()[0];
@@ -191,8 +194,132 @@ function IconButton({
);
}
export function LaneChangesPanel({ laneId }: { laneId: number }) {
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
/** A work-range file's stage / unstage / discard buttons — the overview row's
* and the review header's. */
function FileActions({
f,
busy,
armed,
onAct,
onArm,
}: {
f: LaneChangeFile;
busy: boolean;
armed: boolean;
onAct: FileAct;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => onAct("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => onAct("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={armed ? 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 (armed) onAct("discard", f.path, true);
else onArm(f.path);
}}
>
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
</span>
);
}
/** One overview row. At module scope on purpose: declared inside the panel it
* was a new component type every render, so each poll and each keystroke in
* the commit box remounted every row (focus dropped to <body>). */
function ChangeRow({
f,
range,
busy,
armed,
onOpen,
onAct,
onArm,
}: {
f: LaneChangeFile;
range: LaneChangeRange;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
onOpen: (path: string) => void;
onAct: FileAct;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<li className="group flex items-baseline gap-1">
<button
type="button"
onClick={() => onOpen(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" && (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
)}
</li>
);
}
export function LaneChangesPanel({
laneId,
onNewLane,
}: {
laneId: number;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
@@ -214,6 +341,8 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = 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.
@@ -227,7 +356,21 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
// Every diff read gets a ticket; only the newest may land. A lane switch (or
// a mode/range change) re-issues the read, and without this a slow reply for
// the previous lane painted its files — and its discard buttons — over this one.
const loadSeq = useRef(0);
// The same guard for the side reads (history, tree, whole file), which only
// race across a lane switch: a reply for a lane no longer shown is dropped.
const laneNow = useRef(laneId);
laneNow.current = laneId;
const forLane =
<T,>(id: number, fn: (v: T) => void) =>
(v: T) => {
if (laneNow.current === id) fn(v);
};
const load = useCallback(async () => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const result = await api.lanes.changes(laneId, {
@@ -236,12 +379,30 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
...(mode === "file" && openFile ? { path: openFile, context } : {}),
...(mode === "review" ? { full: true } : {}),
});
if (seq !== loadSeq.current) return;
setData(result);
setError(null);
} catch (err) {
if (seq !== loadSeq.current) return;
// A tracked file opened from Files that has nothing to diff: a normal
// state, not a fault. The whole-file view is the answer; no banner.
if (mode === "file" && (err as { code?: string })?.code === "ENOFILECHANGE") {
setData({
available: true,
range,
base: null,
baseRef: null,
sha,
context,
files: [],
truncated: false,
});
setError(null);
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
if (seq === loadSeq.current) setLoading(false);
}
}, [laneId, range, sha, mode, openFile, context]);
@@ -260,19 +421,19 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.then((r) => setCommits(r.commits))
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId]);
}, [mode, laneId, reread]);
useEffect(() => {
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
void api.lanes
.gitTree(laneId, "")
.then((r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))
.then(forLane(laneId, (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]);
}, [mode, laneId, query, reread]);
// A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => {
@@ -284,36 +445,27 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.then((r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries })))
.then(forLane(laneId, (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.
// "Expand all" is ONE read of the whole tree: a request per folder meant a
// `git ls-files` per folder, seconds of waiting on a large repo.
const expandAllDirs = async () => {
const lane = laneId;
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);
const { levels } = await api.lanes.gitTreeAll(lane);
// A lane switch mid-read must not land lane A's tree in lane B.
if (laneNow.current !== lane) return;
setTreeChildren(levels);
setExpandedDirs(new Set(Object.keys(levels).filter(Boolean)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setTreeBusy(false);
if (laneNow.current === lane) setTreeBusy(false);
}
};
@@ -323,13 +475,19 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setHits(null);
return;
}
// `live` drops an answer that a newer query, a mode change or a lane
// switch has overtaken — a slow "main" must not overwrite "mainx".
let live = true;
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(laneId, q)
.then((r) => setHits(r.paths))
.catch(() => setHits([]));
.then((r) => live && setHits(r.paths))
.catch(() => live && setHits([]));
}, 150);
return () => window.clearTimeout(timer);
return () => {
live = false;
window.clearTimeout(timer);
};
}, [mode, laneId, query]);
// The whole-file read is a different endpoint from the diff, so it is its
@@ -338,8 +496,10 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
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)
.then(
forLane(laneId, (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]);
@@ -353,8 +513,17 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setRange("work");
setTreeChildren({});
setExpandedDirs(new Set());
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(null);
setCollapsed(new Set());
// An armed discard names a PATH, not a file in a lane — carried over, one
// click would discard the same path in the lane just switched to.
setConfirmDiscard(null);
// Same for a half-typed commit message: it describes the OTHER lane's work.
setMessage("");
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
@@ -384,37 +553,116 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setMode("file");
};
const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => {
// An action that resolves after a lane switch belongs to the lane left: its
// `load` is the pre-switch one and would take a fresh ticket and paint the
// old lane's files here, so everything after the await is dropped.
const act = async (
action: "stage" | "unstage" | "discard",
path: string | string[],
confirm?: boolean
) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(laneId, action, { paths: [path], ...(confirm ? { confirm } : {}) });
const paths = Array.isArray(path) ? path : [path];
await api.lanes.gitAction(lane, action, { paths, ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
if (laneNow.current === lane) setBusy(false);
}
};
// One hunk, matched by its `@@` line against a fresh diff at the context
// this diff was read with — so the server never applies a stale patch.
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, {
path,
hunk,
context: data?.available ? data.context : undefined,
...(action === "discard-hunk" ? { confirm: true } : {}),
});
if (laneNow.current !== lane) return;
setArmedHunk(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// Stage / Undo on each hunk of a file with unstaged changes. The diff is
// HEAD → working tree, so a staged-only file gets none: undoing its tree
// would leave the index holding the change.
const hunkActions = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary
? (hunk: LaneDiffHunk) => {
const key = `${f.path}\n${hunk.header}`;
const armed = armedHunk === key;
return (
<>
<IconButton
label={t("changes.stageHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
>
<Plus className="h-3 w-3" />
</IconButton>
<IconButton
label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
danger
onClick={() =>
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
}
>
<Undo2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
</>
);
}
: undefined;
const commitStaged = async () => {
const lane = laneId;
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" });
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
if (laneNow.current !== lane) return;
setMessage("");
refreshGit();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
if (laneNow.current === lane) setBusy(false);
}
};
const rereadAll = () => {
refreshGit();
// The tree cache is dropped whole: after a switch any folder may differ.
setTreeChildren({});
setExpandedDirs(new Set());
setReread((n) => n + 1);
void load();
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
@@ -425,83 +673,27 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
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;
// What "Stage all" sends: every listed file with a change the index lacks.
const toStage = range === "work" ? files.filter((f) => f.untracked || f.unstaged) : [];
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>
);
// The armed path left the list (committed, reverted, lane switched): disarm,
// so a later reappearance starts from the first click again.
useEffect(() => {
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
setConfirmDiscard(null);
}
}, [data, files, confirmDiscard]);
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">
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
{/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is
its own inline box, so a narrow panel wraps BETWEEN groups — never
through one, which is what made the old single row unreadable. */}
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<div className="contents">
{mode === "file" ? (
<button
type="button"
@@ -540,17 +732,17 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
<button
type="button"
onClick={() => void load()}
onClick={rereadAll}
aria-label={t("changes.refresh")}
title={t("changes.refresh")}
className="ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
className="order-last 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="contents">
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["work", "branch"] as const).map((r) => (
<button
@@ -702,7 +894,9 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
</ul>
) : mode === "browse" ? (
<div data-testid="lane-changes-browse">
<div className="mb-2 flex items-center gap-1.5">
{/* Pinned: the finder is what you reach for after scrolling a
long tree, not only from its top. */}
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-3 pb-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -770,40 +964,72 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
<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 ? (
) : files.length === 0 && mode !== "file" ? (
<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 && (
{/* Commit and Stage all sit ABOVE the list and stay pinned while it
scrolls: on a 60-file change-set the box at the bottom was a
scroll away from every file you had just staged. */}
{(stagedCount > 0 || toStage.length > 0) && (
<div
data-testid="lane-changes-commit"
className="mt-2 flex items-center gap-1.5 border-t border-border-light pt-2"
data-testid="lane-changes-actions"
className="sticky top-0 z-10 mb-1.5 space-y-1.5 border-b border-border-light bg-surface-3 pb-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>
{stagedCount > 0 && (
<div data-testid="lane-changes-commit" className="flex items-center gap-1.5">
<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>
)}
{toStage.length > 0 && (
<button
type="button"
disabled={busy}
onClick={() =>
void act(
"stage",
toStage.map((f) => f.path)
)
}
className="flex items-center gap-1 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"
>
<Plus className="h-3 w-3" />
{t("changes.stageAll", { count: toStage.length })}
</button>
)}
</div>
)}
<ul data-testid="lane-changes-overview" className="space-y-0.5">
{files.map((f) => (
<ChangeRow
key={f.path}
f={f}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
))}
</ul>
</>
) : mode === "file" ? (
current || (whole && text) ? (
@@ -829,7 +1055,12 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
)
) : current ? (
<LaneDiffView file={current} layout={layout} fill />
<LaneDiffView
file={current}
layout={layout}
fill
hunkActions={whole ? undefined : hunkActions(current)}
/>
) : null}
</div>
) : (
@@ -849,6 +1080,9 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
tabIndex={0}
onClick={() => toggleCollapsed(f.path)}
onKeyDown={(e) => {
// Only the header's own keys: Enter on the path button
// inside bubbles here and must open, not fold.
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCollapsed(f.path);
@@ -860,7 +1094,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
? 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 ${
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
shut ? "" : "border-b border-border-light"
}`}
>
@@ -886,8 +1120,20 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
{f.path}
</button>
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
{range === "work" && (
// The header folds on click; its buttons must not.
<span className="self-center" onClick={(e) => e.stopPropagation()}>
<FileActions
f={f}
busy={busy}
armed={confirmDiscard === f.path}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
</span>
)}
</div>
{!shut && <LaneDiffView file={f} layout={layout} />}
{!shut && <LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />}
</div>
);
})}
+450 -100
View File
@@ -13,10 +13,23 @@
* - Line pairing for split view happens here, on the rows the server already
* numbered. The server stays the single source of what changed; this file
* only decides how it looks.
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useRef } from "react";
import {
createContext,
useContext,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
@@ -32,6 +45,100 @@ function boxHeight(fill: boolean) {
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
}
/** Rows above which a view windows; at or below it renders exactly as before. */
export const WINDOW_THRESHOLD = 500;
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
export const ROW_PX = 16;
const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX };
/**
* The `[start, end)` item range to render for a scroll position. `scrollTop`
* is clamped to the content first, so a stale offset (content that shrank, a
* layout switch) still lands on real rows instead of an empty window.
*/
export function visibleRange(
scrollTop: number,
viewport: number,
rowHeight: number,
count: number,
overscan: number
) {
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
return { start: Math.min(start, end), end };
}
/**
* Windowing state for one scroller of `count` items. Off (the whole range)
* at or below the threshold.
*/
function useWindow(count: number) {
const [top, setTop] = useState(0);
const on = count > WINDOW_THRESHOLD;
// The offset is tracked even while windowing is off, so a diff that GROWS
// past the threshold (expanded context) windows where the reader is, not at
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
const lastTop = useRef(0);
// count 0 = no scroller here (split layout): the browser drops its offset,
// so coming back to unified must start from 0, not the pre-split row.
if (count === 0) lastTop.current = 0;
const [wasOn, setWasOn] = useState(on);
if (wasOn !== on) {
setWasOn(on);
setTop(on ? lastTop.current : 0);
}
// ponytail: the viewport is taken as the window height — never smaller than
// a box that lives inside the window, so it only over-renders a few dozen
// rows, and it needs no ResizeObserver to follow a panel resize.
const range = on
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
: { start: 0, end: count };
const onScroll = (el: HTMLElement) => {
lastTop.current = el.scrollTop;
if (on) setTop(el.scrollTop);
};
return { on, range, onScroll };
}
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
function longest(texts: Iterable<string>) {
let max = 0;
for (const t of texts) {
let n = t.length;
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
if (n > max) max = n;
}
return max;
}
/** Stands in for `rows` off-screen rows. */
function Spacer({ rows }: { rows: number }) {
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
}
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
function Windowed({
count,
range,
minWidth,
children,
}: {
count: number;
range: { start: number; end: number };
minWidth: string;
children: React.ReactNode;
}) {
return (
<div data-diff-content style={{ minWidth }}>
<Spacer rows={range.start} />
{children}
<Spacer rows={count - range.end} />
</div>
);
}
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) {
const ext = filePath.split(".").pop() || "";
@@ -45,7 +152,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
return (
<>
<Code text={text.slice(0, from)} lang={lang} />
<span className="rounded-sm bg-white/15">
<span className="rounded-sm bg-fg-primary/15">
<Code text={text.slice(from, to)} lang={lang} />
</span>
<Code text={text.slice(to)} lang={lang} />
@@ -86,26 +193,51 @@ function emphasis(
};
}
/** 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 }> = [];
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
type EmphPair = { del: [number, number]; add: [number, number] };
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
* index in `hunk.lines` (what the unified view renders by). */
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
// columns and every re-render of the same data share one pass. A refetch
// brings new objects and the old entries go with them.
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
/** Pair each run of removed lines with the run of added lines following it,
* carrying indices so emphasis lands without searching the hunk again. */
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
const hit = analysed.get(hunk);
if (hit) return hit;
const lines = hunk.lines;
const rows: PairRow[] = [];
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
out.push({ left: line, right: line });
rows.push({ left: line, right: line, emph: null });
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 });
const delStart = i;
while (i < lines.length && lines[i]!.type === "del") i += 1;
const addStart = i;
while (i < lines.length && lines[i]!.type === "add") i += 1;
const dels = addStart - delStart;
const adds = i - addStart;
for (let k = 0; k < Math.max(dels, adds); k += 1) {
const left = k < dels ? lines[delStart + k]! : null;
const right = k < adds ? lines[addStart + k]! : null;
const e = left && right ? emphasis(left.text, right.text) : null;
if (e) {
emph.set(delStart + k, e.del);
emph.set(addStart + k, e.add);
}
rows.push({ left, right, emph: e });
}
}
const out = { rows, emph };
analysed.set(hunk, out);
return out;
}
@@ -115,55 +247,166 @@ function Gutter({ n }: { n: number | null }) {
);
}
/** 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];
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
* `style` pins a windowed row's height; unwindowed rows carry none. */
function Row({
tint,
style,
children,
}: {
tint: string;
style?: CSSProperties;
children: React.ReactNode;
}) {
return (
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
<div
className={`flex w-max min-w-full ${tint}`}
style={style}
data-diff-item={style ? "" : undefined}
>
{children}
</div>
);
}
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
* separate from context at a glance on every theme (the /10 fill alone did not). */
function rowTint(type: LaneDiffLine["type"] | null) {
if (type === "add")
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
return "";
}
function markerClass(type: LaneDiffLine["type"]) {
if (type === "add") return "font-bold text-status-success";
if (type === "del") return "font-bold text-status-danger";
return "text-fg-muted";
}
/** Per-hunk controls (stage / undo) beside each `@@` line. A context rather
* than a prop because the header renders from four places. */
const HunkActions = createContext<((hunk: LaneDiffHunk) => ReactNode) | null>(null);
function HunkHeader({
hunk,
fixed,
noActions,
}: {
hunk: LaneDiffHunk;
fixed?: boolean;
/** The split view's left column: one set of controls per hunk, not two. */
noActions?: boolean;
}) {
const actions = useContext(HunkActions);
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
<div
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
>
<span className="min-w-0 flex-1 truncate">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</span>
{actions && !noActions && (
<span className="sticky right-2 flex shrink-0 items-center gap-0.5">{actions(hunk)}</span>
)}
</div>
);
}
function UnifiedLine({
line,
lang,
emph,
style,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
}) {
return (
<Row tint={rowTint(line.type)} style={style}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
</Row>
);
}
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);
}
const { emph } = analyseHunk(hunk);
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>
<UnifiedLine key={i} line={line} lang={lang} emph={emph.get(i)} />
))}
</div>
);
}
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
* unified, a paired row in split. */
type Item = { hunk: LaneDiffHunk; i: number };
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
const out: Item[] = [];
for (const hunk of hunks) {
out.push({ hunk, i: -1 });
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
}
return out;
}
function longestInHunks(hunks: LaneDiffHunk[]) {
return longest(
(function* () {
for (const h of hunks) for (const l of h.lines) yield l.text;
})()
);
}
function SplitLine({
row,
lang,
side,
style,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
}) {
const line = side === "left" ? row.left : row.right;
return (
<Row tint={rowTint(line?.type ?? null)} style={style}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code
text={line.text}
lang={lang}
emph={side === "left" ? row.emph?.del : row.emph?.add}
/>
) : null}
</span>
</Row>
);
}
/** One side of the split view: a hunk header, then that side's rows. */
function SplitColumn({
hunks,
@@ -178,43 +421,104 @@ function SplitColumn({
<>
{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>
);
})}
<HunkHeader hunk={hunk} noActions={side === "left"} />
{analyseHunk(hunk).rows.map((row, i) => (
<SplitLine key={i} row={row} lang={lang} side={side} />
))}
</div>
))}
</>
);
}
/** Both sides, each scrolling horizontally on its own but kept in step. */
/** A windowed split column: the shared range of items, one side of each. */
function SplitSlice({
items,
range,
lang,
side,
}: {
items: Item[];
range: { start: number; end: number };
lang: string;
side: "left" | "right";
}) {
return (
<>
{items
.slice(range.start, range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed noActions={side === "left"} />
) : (
<SplitLine
key={range.start + k}
row={analyseHunk(hunk).rows[i]!}
lang={lang}
side={side}
style={FIXED}
/>
)
)}
</>
);
}
/** `v` held inside a scroller's own extent. */
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
return Math.min(v, Math.max(0, scrollSize - clientSize));
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
];
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
function follow(src: HTMLElement, dst: HTMLElement) {
for (const [pos, size, client] of AXES) {
// `src` pinned at its own end while `dst` sits past it: `src` is the
// narrower column echoing a clamp we just caused. Pulling `dst` back to
// it would snap the wide column home every time it scrolls past the
// narrow one's longest line.
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
const target = clampScroll(src[pos], dst[size], dst[client]);
if (dst[pos] !== target) dst[pos] = target;
}
}
/** Both sides, each its own scroller but kept in step on both axes. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
const win = useWindow(items.length);
// Mirroring fires the other pane's own scroll handler, which mirrors back —
// the equality check is what ends that, so no re-entry flag is needed.
const sync = useCallback((from: "left" | "right") => {
// the equality check (and `follow`'s edge rule) is what ends that, so no
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
// vertical drift puts a deletion beside some unrelated line. Both columns
// window off the same offset, so they always hold the same row range.
const sync = (from: "left" | "right") => {
const src = from === "left" ? left.current : right.current;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst || dst.scrollLeft === src.scrollLeft) return;
dst.scrollLeft = src.scrollLeft;
}, []);
if (!src || !dst) return;
follow(src, dst);
win.onScroll(src);
};
const column = (side: "left" | "right") =>
win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
</Windowed>
) : (
<SplitColumn hunks={hunks} lang={lang} side={side} />
);
return (
<div className={`flex ${boxHeight(fill)}`}>
@@ -224,7 +528,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
onScroll={() => sync("left")}
className="w-1/2 min-w-0 overflow-auto"
>
<SplitColumn hunks={hunks} lang={lang} side="left" />
{column("left")}
</div>
<div
ref={right}
@@ -232,7 +536,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
onScroll={() => sync("right")}
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
>
<SplitColumn hunks={hunks} lang={lang} side="right" />
{column("right")}
</div>
</div>
);
@@ -243,14 +547,26 @@ export function LaneDiffView({
file,
layout,
fill = false,
hunkActions,
}: {
file: LaneChangeFile;
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
/** Controls drawn on each hunk's header row. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
const hunks = file.hunks;
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
const minWidth = useMemo(
// two gutters + the row's px-2, plus the marker column
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
[hunks]
);
// Unified only; split windows inside its own two scrollers.
const win = useWindow(layout === "unified" ? items.length : 0);
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -258,24 +574,45 @@ export function LaneDiffView({
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>
<HunkActions.Provider value={hunkActions ?? null}>
<div
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
{items
.slice(win.range.start, win.range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
) : (
<UnifiedLine
key={win.range.start + k}
line={hunk.lines[i]!}
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
/>
)
)}
</Windowed>
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
))
)}
{file.truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
</HunkActions.Provider>
);
}
@@ -294,19 +631,32 @@ export function LaneFileView({
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
const win = useWindow(lines.length);
// gutter + the row's px-2
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
);
return (
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
>
{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>
))}
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
{lines
.slice(win.range.start, win.range.end)
.map((text, k) => row(text, win.range.start + k, FIXED))}
</Windowed>
) : (
lines.map((text, i) => row(text, i))
)}
{truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
+125 -122
View File
@@ -1,13 +1,14 @@
/**
* @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.
* fetch, pull, push, switch branch, and create/delete a branch. Committing is
* NOT here: it is the Changes panel's staged-only commit box, beside the
* per-file stage/unstage/discard it commits — one way to commit, and it only
* ever takes what the user staged.
*
* It renders 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.
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
@@ -15,23 +16,13 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
Check,
GitBranch,
Plus,
RefreshCw,
Trash2,
Upload,
X,
} from "lucide-react";
import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } 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";
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete";
function Btn({
label,
@@ -39,6 +30,7 @@ function Btn({
busy,
disabled,
title,
compact,
onClick,
}: {
label: string;
@@ -46,6 +38,8 @@ function Btn({
busy?: boolean;
disabled?: boolean;
title?: string;
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
compact?: boolean;
onClick: () => void;
}) {
return (
@@ -54,10 +48,13 @@ function Btn({
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"
aria-label={compact ? label : undefined}
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
}`}
>
{icon}
{label}
{!compact && label}
</button>
);
}
@@ -67,9 +64,13 @@ export function LaneGitBar({
git,
onChanged,
onNewLane,
lead,
}: {
laneId: number;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
lead?: React.ReactNode;
/** Called after any action that could have moved the working copy. */
onChanged: () => void;
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
@@ -79,32 +80,52 @@ export function LaneGitBar({
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("");
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
// render the moment the id changes, before anything can act on it.
const [shownLane, setShownLane] = useState(laneId);
// One action at a time, checked synchronously: `busy` is state, and a second
// Enter can land before the render that disables anything.
const inFlight = useRef(false);
if (shownLane !== laneId) {
setShownLane(laneId);
setBusy(null);
setError(null);
setSwitching(false);
setBranches(null);
setWorktrees([]);
setPicked("");
setNewBranch("");
inFlight.current = false;
}
const laneNow = useRef(laneId);
laneNow.current = laneId;
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
const run = async (
action: Action,
body?: { message?: string; only?: "staged"; branch?: string }
) => {
const run = async (action: Action, body?: { branch?: string }) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
setBusy(action);
setError(null);
try {
await api.lanes.gitAction(laneId, action, body || {});
setCommitting(false);
setMessage("");
await api.lanes.gitAction(lane, action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
// 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("");
if (action === "switch" || action.startsWith("branch-")) {
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
setBranches(null);
await refreshBranches();
@@ -113,19 +134,32 @@ export function LaneGitBar({
} 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));
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
if (laneNow.current === lane)
setError(
(err as { code?: string })?.code === "ENOREMOTE"
? t("gitBar.errNoRemote")
: err instanceof Error
? err.message
: String(err)
);
} finally {
setBusy(null);
if (laneNow.current === lane) {
inFlight.current = false;
setBusy(null);
}
}
};
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
} catch {
setBranches([]);
if (laneNow.current === laneId) setBranches([]);
}
};
@@ -141,15 +175,19 @@ export function LaneGitBar({
);
return (
<div data-testid="lane-git-bar" className="mb-2 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<div data-testid="lane-git-bar" className="space-y-1">
<div className="flex min-w-0 items-center gap-2">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{git.upstream ? (
{/* Null counts with an upstream named means git could not compare
the two (the upstream ref is gone): claiming "in sync" there
would be a lie, so it reads as not pushed. */}
{git.upstream && git.ahead != null && git.behind != null ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
className="flex items-center gap-1 font-mono text-[11px] text-fg-muted"
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
>
{(git.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning">
@@ -167,95 +205,52 @@ export function LaneGitBar({
</span>
) : (
git.hasRemote && (
<span className="font-mono text-[11px] text-fg-muted">{t("gitBar.noUpstream")}</span>
<span className="shrink-0 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"
<div className="ml-auto flex shrink-0 items-center">
<Btn
compact
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3.5 w-3.5" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
label={t("gitBar.commitAll", { count: dirty })}
busy={busy === "commit"}
disabled={!message.trim()}
onClick={() => void run("commit", { message })}
compact
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
{/* 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" })}
compact
label={t("gitBar.push")}
icon={<Upload className="h-3.5 w-3.5" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pushHint")}
onClick={() => run("push")}
/>
<Btn
compact
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
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}
/>
<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>
)}
</div>
{switching && (
<div className="flex items-center gap-1.5">
@@ -289,8 +284,16 @@ export function LaneGitBar({
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
title={t("gitBar.newLaneHint")}
disabled={!picked}
// A branch any worktree holds — this lane's own included — is
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
title={
picked && (heldBy.has(picked) || picked === git.branch)
? t("gitBar.newLaneHeld", {
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
@@ -0,0 +1,294 @@
/**
* @file The top of the Changes panel: the lane's working-copy line (branch,
* head, subject, and the uncommitted counts that open a per-file hover panel)
* and the git action bar under it (`LaneGitBar`).
*
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
* owned by the panel) and refresh each other after every write.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
/** How often the mounted panel re-reads its working-copy facts. Slow on
* purpose: each call is five git subprocesses server-side, and a branch name
* does not change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;
/**
* The lane's own working copy, fetched by the Changes panel rather than folded
* into the polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders with no git header.
*/
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
const [nonce, setNonce] = useState(0);
// A lane switch drops the previous lane's facts during render, so nothing
// (the git bar's "current branch" included) shows them until the new read.
const [factsLane, setFactsLane] = useState(laneId);
if (factsLane !== laneId) {
setFactsLane(laneId);
setFacts(null);
}
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
.catch(() => {
// Silent: a panel that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip.
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
/** Porcelain status code -> the letter shown and the colour it is shown in.
* Keyed on the code's first meaningful character, so an index-side change
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
const GIT_STATUS_STYLE: Record<string, string> = {
M: "text-status-warning",
A: "text-status-success",
D: "text-status-danger",
R: "text-accent",
C: "text-accent",
U: "text-status-danger",
"?": "text-fg-muted",
};
/**
* The file list behind a lane's uncommitted counts: which files, what happened
* to each, and how many lines it gained and lost.
*
* It replaced a native `title` tooltip, which could show the same paths but
* neither colour them nor align the deltas — and a plain porcelain dump is not
* something you read at a glance.
*
* PORTALLED to `document.body` and `position: fixed`: the Changes panel
* scrolls in its own `overflow` box, and an absolutely positioned panel would
* be clipped by it. It flips above its anchor rather than off the bottom of
* the window, and clamps to the right edge.
*/
function LaneGitFilesPanel({
files,
hidden,
anchor,
onPointerEnter,
onPointerLeave,
}: {
files: LaneGitFile[];
hidden: number;
/** The counts button this panel hangs off. */
anchor: HTMLElement | null;
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// After the first paint, when the panel's real height is known: place it
// under the anchor, or above it when that would run off the bottom.
useLayoutEffect(() => {
if (!anchor || !panelRef.current) return;
const a = anchor.getBoundingClientRect();
const p = panelRef.current.getBoundingClientRect();
const gap = 4;
const top =
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
setPos({
top: Math.max(8, top),
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
});
}, [anchor, files]);
// Totals are over the LISTED files only — the cap means the rest are unknown,
// and a total that silently omits them would be a wrong number rather than a
// partial one. `hidden` says how many are missing.
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
return createPortal(
<div
ref={panelRef}
data-testid="lane-git-files"
role="group"
aria-label={t("git.filesHeading", { count: files.length })}
// The pointer has to be able to travel INTO the panel to scroll it, so
// the panel answers the same enter/leave pair the trigger does.
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
style={{
position: "fixed",
zIndex: 60,
top: pos?.top ?? 0,
left: pos?.left ?? 0,
// Hidden for the one frame before it has been measured, so it never
// flashes at the top-left corner.
visibility: pos ? "visible" : "hidden",
}}
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
>
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
<span className="ml-auto shrink-0 font-mono tabular-nums">
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
</span>
</div>
<ul className="space-y-0.5 font-mono text-[11px]">
{files.map((f) => {
const code = f.status.trim()[0] || "?";
return (
<li key={f.path} className="flex items-baseline gap-2">
<span
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
title={f.status}
>
{code}
</span>
{/* The DIRECTORY truncates, never the filename: the name is what
you scan for, the path is only there to disambiguate it. */}
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
</span>
<span className="shrink-0 tabular-nums">
{f.added === null && f.deleted === null ? (
<span className="text-fg-muted">{t("git.noDelta")}</span>
) : (
<>
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
<span className="text-status-danger">−{f.deleted ?? 0}</span>
</>
)}
</span>
</li>
);
})}
</ul>
{hidden > 0 && (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
)}
</div>,
document.body
);
}
export function LaneGitHeader({
laneId,
git,
onChanged,
onNewLane,
}: {
laneId: number;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
/** See `LaneGitBar`'s prop of the same name. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
// Closing is DELAYED: the panel is portalled away from the trigger, so the
// pointer leaves the trigger before it can land on the panel it is heading
// for. Any enter — trigger or panel — cancels the pending close.
const filesCloseTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
if (!git?.available) return null;
const lead = (
<>
{/* One row with the actions: branch first and strongest, then the head
and its subject, which is what truncates when the panel is narrow
(the whole of it stays in the tooltip). */}
<div
data-testid="lane-git"
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
title={`${git.head} ${git.subject}`}
>
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<GitBranch className="h-3 w-3 text-fg-muted" />
{git.branch}
</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<button
type="button"
ref={setCountsAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
onClick={() => setFilesOpen((v) => !v)}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</button>
)}
</>
);
return (
<div
data-testid="lane-git-header"
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
>
<LaneGitBar
laneId={laneId}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
lead={lead}
/>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div>
);
}
@@ -245,10 +245,11 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
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.
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
// the form cannot see, so the placeholder only says the field is optional.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"feat/auto-named"
"Automatic — named from the title"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
@@ -503,6 +504,7 @@ describe("AddLaneModal — repo mode (adopt)", () => {
cwd: "/Users/tester/projects/repo",
title: "main repo",
tool: "claude",
exact: true,
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
@@ -511,6 +513,28 @@ describe("AddLaneModal — repo mode (adopt)", () => {
// close right away like the old adopt flow did.
expect(onClose).toHaveBeenCalled();
});
it("says the folder already has a lane instead of closing silently (#42)", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 3, kind: "adopted", status: "idle", title: "SG - Services" }),
created: false,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument();
// The existing lane is selected so the user sees it, but nothing claims a new one.
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — folder browse", () => {
@@ -9,7 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
@@ -204,126 +204,11 @@ describe("LaneCard rebuilt layout", () => {
});
describe("LaneCard git block", () => {
const facts = {
available: true as const,
branch: "feat/rename-metric",
head: "9b3e74a",
subject: "free-text rule mode in the form",
dirty: 2,
untracked: 1,
files: [
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
{ path: "scratch.txt", status: "??", added: null, deleted: null },
],
// Tracking facts ride along on the same endpoint; a branch that has never
// been pushed reports nulls rather than zeros.
hasRemote: true,
upstream: null,
ahead: null,
behind: null,
};
it("renders the live branch, head, subject and uncommitted counts", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(block.textContent).toContain("2");
// The live branch replaces the lane's recorded one rather than doubling it.
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
});
it("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()} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).not.toContain("modified");
});
it("renders the card with no git block and no error when git is unavailable", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
it("carries no working-copy row — that heads the Changes panel now", async () => {
render(<LaneCard lane={makeLane({ id: 4 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-4")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
});
it("swallows a rejected request instead of surfacing an error", async () => {
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
expect(screen.queryByTestId("lane-git")).toBeNull();
expect(screen.queryByText(/network down/)).toBeNull();
});
it("stops polling once the card unmounts", async () => {
vi.useFakeTimers();
try {
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
unmount();
vi.advanceTimersByTime(120_000);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
expect(api.lanes.git).not.toHaveBeenCalled();
});
});
@@ -11,7 +11,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
@@ -20,12 +20,15 @@ import type { LaneChanges } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
git: vi.fn(),
gitBranches: vi.fn(),
changes: vi.fn(),
gitAction: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
},
},
}));
@@ -77,6 +80,7 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
}
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
vi.mocked(api.lanes.gitAction).mockReset();
@@ -94,12 +98,14 @@ beforeEach(() => {
},
],
});
vi.mocked(api.lanes.gitLs).mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitLs)
.mockReset()
.mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -211,6 +217,89 @@ describe("LaneChangesPanel", () => {
});
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
branch: "feat/thing",
head: "9b3e74a",
subject: "wip",
dirty: 2,
untracked: 1,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
expect(api.lanes.git).toHaveBeenCalledTimes(1);
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
// The header's counts moved with the index: waiting out its 30s poll
// would leave them wrong beside the list that just changed.
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledTimes(2));
});
it("re-reads the diff after a git bar action", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
branch: "feat/thing",
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: true,
upstream: "origin/feat/thing",
ahead: 0,
behind: 1,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-git-bar");
const before = vi.mocked(api.lanes.changes).mock.calls.length;
await user.click(screen.getByRole("button", { name: /Pull/ }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "pull", {});
// A pull rewrites the working tree the list below describes.
await waitFor(() =>
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before)
);
});
it("re-reads history too when Refresh is pressed there", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
await screen.findByText("feat: the commit");
vi.mocked(api.lanes.gitLog).mockClear();
await user.click(screen.getByRole("button", { name: "Refresh" }));
// A pull or a switch from the git bar moves the commit list as well.
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
});
it("stages every file the index lacks in one call, from above the list", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const actions = screen.getByTestId("lane-changes-actions");
// Commit box and Stage all come BEFORE the file list.
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
// The already-staged docs/API.md is not sent again.
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js", "notes.txt"],
});
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -229,6 +318,76 @@ describe("LaneChangesPanel", () => {
});
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
const user = userEvent.setup();
const { rerender } = 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!);
// Lane 8 has a file at the SAME path.
rerender(<LaneChangesPanel laneId={8} />);
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(8, expect.anything()));
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
const [again] = await screen.findAllByRole("button", { name: "Discard this file's changes" });
await user.click(again!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("disarms a pending discard once that path leaves the file list", 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!);
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
const base = changes();
const rest = base.available ? base.files.slice(1) : [];
vi.mocked(api.lanes.changes).mockResolvedValueOnce(changes({ files: rest }));
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
});
it("ignores a late response for the lane it has already left", async () => {
let resolveOld: (v: LaneChanges) => void = () => {};
vi.mocked(api.lanes.changes).mockImplementation((id) =>
id === 7
? new Promise<LaneChanges>((r) => (resolveOld = r))
: Promise.resolve(
changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
)
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
});
it("opens a commit from history as a commit-range review", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -275,8 +434,16 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
it("expands every folder at once, fetching whatever level wasn't cached yet", async () => {
it("expands every folder at once from one whole-tree read", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTreeAll).mockResolvedValue({
available: true,
truncated: false,
levels: {
"": [{ name: "src", path: "src", dir: true }],
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
});
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => {
if (path === "src")
return {
@@ -411,6 +578,16 @@ describe("LaneChangesPanel", () => {
});
});
it("drops a half-typed commit message on a lane switch", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: meant for lane 7");
rerender(<LaneChangesPanel laneId={8} />);
await screen.findByTestId("lane-changes-commit");
expect(screen.getByLabelText("Commit message")).toHaveValue("");
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
@@ -491,6 +668,97 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
});
describe("review actions", () => {
const reviewed = () =>
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 1,
binary: false,
hunks: [HUNK, { ...HUNK, header: "@@ -9,2 +10,3 @@" }],
},
{
path: "notes.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
hunks: [HUNK],
},
],
});
const openReview = async (user: ReturnType<typeof userEvent.setup>) => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(reviewed());
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
};
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked one has none to act on.
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
expect(stage).toHaveLength(2);
await user.click(stage[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -9,2 +10,3 @@",
context: 3,
});
});
it("needs two clicks to undo a hunk, and arms only that hunk", async () => {
const user = userEvent.setup();
await openReview(user);
const [first] = screen.getAllByRole("button", { name: "Undo this hunk" });
await user.click(first!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
// The other hunk is still at its first click.
expect(screen.getAllByRole("button", { name: "Undo this hunk" })).toHaveLength(1);
await user.click(
screen.getByRole("button", {
name: "Click again to undo this hunk — this cannot be undone",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,2 +1,3 @@",
context: 3,
confirm: true,
});
});
it("stages a whole file from its review header without folding it", async () => {
const user = userEvent.setup();
await openReview(user);
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stageFile!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
).toBeInTheDocument();
});
it("keeps each file's header pinned while its diff scrolls", async () => {
const user = userEvent.setup();
await openReview(user);
expect(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })).toHaveClass(
"sticky",
"top-0"
);
});
});
it("opens a file only from its path, and folds it from anywhere else", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -515,6 +783,73 @@ describe("LaneChangesPanel", () => {
);
});
it("opens, not folds, when Enter is pressed on the path", 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");
screen.getByRole("button", { name: "server/lib/git-ops.js" }).focus();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("drops a quick-open answer for the lane it has already left", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs).mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
);
const { rerender } = 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 waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
rerender(<LaneChangesPanel laneId={8} />);
await act(async () => answer({ paths: ["from/lane7.ts"] }));
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
await user.click(screen.getByRole("button", { name: "Files" }));
expect(screen.queryByText("from/lane7.ts")).toBeNull();
});
it("keeps the newest quick-open answer when an older one lands after it", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs)
.mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
)
.mockResolvedValueOnce({
available: true,
paths: ["new/mainx.ts"],
truncated: false,
total: 1,
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const box = await screen.findByLabelText("Find a file by path…");
await user.type(box, "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
await user.type(box, "x");
expect(await screen.findByText("new/mainx.ts")).toBeInTheDocument();
await act(async () => answer({ paths: ["old/main-only.ts"] }));
expect(screen.queryByText("old/main-only.ts")).toBeNull();
});
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} />);
@@ -537,4 +872,148 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
});
it("opens an unchanged tracked file from Files with no error banner", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockRejectedValue(
Object.assign(new Error("file has no changes in this range"), {
code: "ENOFILECHANGE",
status: 400,
})
);
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"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledTimes(3));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
});
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with ONE request, not one per folder", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTree).mockClear();
vi.mocked(api.lanes.gitTreeAll)
.mockClear()
.mockResolvedValue({
available: true,
truncated: false,
levels: {
"": [{ name: "src", path: "src", dir: true }],
src: [{ name: "main.ts", path: "src/main.ts", dir: 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();
expect(api.lanes.gitTreeAll).toHaveBeenCalledTimes(1);
// Only the root read the Files tab made on entry — no per-folder walk.
expect(api.lanes.gitTree).toHaveBeenCalledTimes(1);
});
it("does not repaint the previous lane after its git action resolves post-switch", async () => {
const user = userEvent.setup();
let resolveAction: (v: { action: string; ok: true; output: string }) => void = () => {};
vi.mocked(api.lanes.gitAction).mockReturnValue(
new Promise((r) => (resolveAction = r)) as ReturnType<typeof api.lanes.gitAction>
);
vi.mocked(api.lanes.changes).mockImplementation(async (id) =>
id === 7
? changes()
: changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
const lane7Reads = vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7).length;
resolveAction({ action: "stage", ok: true, output: "" });
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7)).toHaveLength(
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
const user = userEvent.setup();
let resolveSrc: () => void = () => {};
vi.mocked(api.lanes.gitTreeAll).mockImplementation(
() =>
new Promise(
(r) =>
(resolveSrc = () =>
r({
available: true,
truncated: false,
levels: {
"": [{ name: "src", path: "src", dir: true }],
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
}))
)
);
vi.mocked(api.lanes.gitTree).mockImplementation((id) => {
return Promise.resolve({
available: true,
path: "",
entries: [
id === 7
? { name: "src", path: "src", dir: true }
: { name: "lib8", path: "lib8", dir: false },
],
truncated: false,
});
});
const { rerender } = 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" }));
rerender(<LaneChangesPanel laneId={8} />);
await user.click(await screen.findByRole("button", { name: "Files" }));
expect(await screen.findByText("lib8")).toBeInTheDocument();
resolveSrc();
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lib8")).toBeInTheDocument();
expect(screen.queryByText("src")).toBeNull();
expect(screen.queryByText("main.ts")).toBeNull();
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
});
});
@@ -3,13 +3,21 @@
* @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.
* opposite each other. Also pins the clamped mirroring (a narrow column may
* not drag the wide one back) and the row windowing of large diffs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { LaneDiffView } from "../LaneDiffView";
import {
LaneDiffView,
LaneFileView,
ROW_PX,
WINDOW_THRESHOLD,
analyseHunk,
visibleRange,
} from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
@@ -33,6 +41,17 @@ const FILE: LaneChangeFile = {
],
};
/** jsdom has no layout: give a scroller the extents a browser would. */
function layout(
el: HTMLElement,
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
) {
for (const [k, v] of Object.entries(g))
Object.defineProperty(el, k, { value: v, configurable: true });
}
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
describe("LaneDiffView — side by side", () => {
it("gives each column its own scroller", () => {
render(<LaneDiffView file={FILE} layout="split" />);
@@ -56,6 +75,8 @@ describe("LaneDiffView — side by side", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollLeft = 320;
fireEvent.scroll(left);
@@ -66,9 +87,295 @@ describe("LaneDiffView — side by side", () => {
expect(left.scrollLeft).toBe(0);
});
it("keeps the split columns on the same row vertically too", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollTop = 480;
fireEvent.scroll(left);
expect(right.scrollTop).toBe(480);
right.scrollTop = 40;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(40);
});
it("clamps the mirrored offset to the other column's own extent", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
right.scrollLeft = 600;
right.scrollTop = 250;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(300);
expect(left.scrollTop).toBe(100);
});
it("does not let the narrow column's clamped echo drag the wide one back", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200 });
right.scrollLeft = 600;
fireEvent.scroll(right);
// A browser fires the narrow column's scroll for the clamped write.
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(600);
// A real move of the narrow column still carries the wide one.
left.scrollLeft = 100;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(100);
});
it("keeps the unified layout on one scroller", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
});
});
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
}
const ROWS = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
]);
/** The unified row whose text contains `needle`. */
function rowOf(container: HTMLElement, needle: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
r.textContent?.includes(needle)
);
if (!row) throw new Error(`no row for ${needle}`);
return row;
}
describe("LaneDiffView — row styling", () => {
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const add = rowOf(container, "x = 2");
const del = rowOf(container, "x = 1");
const ctx = rowOf(container, "keep()");
expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger");
expect(ctx.className).not.toMatch(/status-(success|danger)/);
});
it("colours the +/- marker bold in its status colour, context marker muted", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "x = 2"));
const minus = marker(rowOf(container, "x = 1"));
const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold");
expect(plus.className).toContain("text-status-success");
expect(minus.textContent).toBe("-");
expect(minus.className).toContain("text-status-danger");
expect(ctx.className).toContain("text-fg-muted");
expect(ctx.className).not.toContain("font-bold");
});
it("emphasises only the changed span of a paired del/add", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
(e) => e.textContent
);
expect(emph).toEqual(["1", "2"]);
});
it("does not emphasise a line that changed completely", () => {
const { container } = render(
<LaneDiffView
file={fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
])}
layout="unified"
/>
);
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
});
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
render(
<LaneDiffView
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
layout="split"
/>
);
const rows = (id: string) => [
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
];
const [right] = rows("lane-diff-split-right");
const [left] = rows("lane-diff-split-left");
expect(right!.className).toContain("bg-status-success/20");
expect(left!.className).not.toMatch(/status-(success|danger)/);
});
});
describe("analyseHunk", () => {
const hunk = {
header: "@@ -1,4 +1,4 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
],
};
it("pairs each del/add with its emphasis, keyed by line index", () => {
const a = analyseHunk(hunk);
expect(a.rows).toHaveLength(3);
expect(a.emph.get(1)).toEqual([6, 7]);
expect(a.emph.get(2)).toEqual([6, 7]);
expect(a.emph.get(3)).toEqual([6, 7]);
expect(a.emph.get(4)).toEqual([6, 7]);
expect(a.emph.has(0)).toBe(false);
});
it("computes once per hunk, so both split columns and re-renders share it", () => {
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
});
});
describe("visibleRange", () => {
it("covers the viewport plus overscan on both sides", () => {
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
});
it("clamps a scrollTop past the content to the last full viewport", () => {
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
});
it("never runs past a short list", () => {
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
});
});
function bigFile(n: number): LaneChangeFile {
const lines = Array.from({ length: n }, (_, i) =>
i % 2
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
);
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
return fileWith(lines);
}
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
describe("LaneDiffView — windowing", () => {
it("renders every row below the threshold, with no spacers", () => {
const { container } = render(
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
);
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
});
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const rows = rowsIn(container);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(300);
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
// header + 5000 lines, every item one fixed row tall
const rendered = container.querySelectorAll("[data-diff-item]").length;
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
});
it("moves the window with the scroll", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
expect(container.textContent).not.toContain("line 3000;");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("line 3000;");
expect(container.textContent).not.toContain("line 10;");
});
it("starts windowing where the reader is when a diff grows past the threshold", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 350 * ROW_PX;
fireEvent.scroll(view);
// Expanding context replaces the file in place, same mounted view.
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
expect(container.textContent).toContain("line 350;");
});
it("starts from the top again after a round trip through split", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
// In split the outer box is no scroller; the browser drops its offset.
screen.getByTestId("lane-diff-view").scrollTop = 0;
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
expect(container.textContent).toContain("line 5;");
});
it("holds the horizontal extent at the longest line, rendered or not", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
// the 300-w line 7 is off screen now, and still sets the width
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
});
it("windows both split columns over the same row range", () => {
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
right.scrollTop = 2000 * ROW_PX;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(2000 * ROW_PX);
const l = rowsIn(left);
const r = rowsIn(right);
expect(l.length).toBeLessThan(300);
expect(l).toHaveLength(r.length);
expect(right.textContent).toContain("line 2001;");
// same rows: the right column's add has its filler row on the left
expect(left.textContent).toContain("line 2000;");
});
it("windows a whole-file read too", () => {
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
expect(rowsIn(container).length).toBeLessThan(300);
const view = screen.getByTestId("lane-file-view");
view.scrollTop = 4000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("row 4000");
});
});
@@ -1,8 +1,8 @@
/**
* @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
* buttons a repo with no remote may press, that it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -70,28 +70,13 @@ describe("LaneGitBar", () => {
);
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();
// Local actions stay available — a repo with no remote still branches.
expect(screen.getByRole("button", { name: /Branch/ })).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("offers no commit at all — that is the Changes panel's staged-only box", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
@@ -120,6 +105,15 @@ describe("LaneGitBar", () => {
);
});
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
@@ -146,20 +140,32 @@ describe("LaneGitBar", () => {
expect(onNewLane).toHaveBeenCalledWith("development");
});
it("commits only the index when asked", async () => {
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing", "feat/free"],
current: "feat/thing",
worktrees: [
{ path: "/repo/lane3", branch: "feat/thing" },
{ path: "/repo/dev", branch: "development" },
],
});
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const newLane = () => screen.getByRole("button", { name: "New lane here" });
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" }));
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
message: "fix: staged only",
only: "staged",
})
);
await user.selectOptions(select, "feat/free");
expect(newLane()).toBeEnabled();
});
it("prints git's own rejection text when an action fails", async () => {
@@ -172,4 +178,80 @@ describe("LaneGitBar", () => {
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
});
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
});
it("sends one branch-create for a double Enter", async () => {
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
});
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
new Promise((r) => {
resolveOld = r;
})
);
const user = userEvent.setup();
const { rerender } = render(
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
const newLane = screen.getByRole("button", { name: "New lane here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
});
it("claims no sync state when the upstream comparison could not be counted", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
onChanged={vi.fn()}
/>
);
expect(screen.queryByText("in sync")).toBeNull();
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
});
it("explains a push with nowhere to go instead of the server's generic text", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
"No remote to push to or pull from"
);
});
});
@@ -0,0 +1,160 @@
/**
* @file LaneGitHeader.test.tsx
* @description Pins the Changes panel's working-copy line: the live branch,
* head, subject and uncommitted counts, the per-file hover panel behind the
* counts, that a lane switch drops the previous lane's facts at once, and that
* an unreadable repo renders nothing rather than an error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
});
/** The panel's own wiring: one facts read feeding the header. */
function Harness({ laneId }: { laneId: number }) {
const [git, refresh] = useLaneGitFacts(laneId);
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
}
describe("LaneGitHeader", () => {
const facts = {
available: true as const,
branch: "feat/rename-metric",
head: "9b3e74a",
subject: "free-text rule mode in the form",
dirty: 2,
untracked: 1,
files: [
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
{ path: "scratch.txt", status: "??", added: null, deleted: null },
],
// Tracking facts ride along on the same endpoint; a branch that has never
// been pushed reports nulls rather than zeros.
hasRemote: true,
upstream: null,
ahead: null,
behind: null,
};
it("renders the live branch, head, subject and uncommitted counts", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
});
it("drops the previous lane's facts the moment the lane changes", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
const { rerender } = render(<Harness laneId={1} />);
await screen.findByText(/feat\/rename-metric/);
// The next lane's read never lands: nothing of lane 1's git may linger.
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
rerender(<Harness laneId={2} />);
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
});
it("names the files behind the counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
// Hidden until asked for: the row is a one-line summary until you hover.
expect(screen.queryByTestId("lane-git-files")).toBeNull();
fireEvent.mouseEnter(counts);
const panel = screen.getByTestId("lane-git-files");
expect(panel.textContent).toContain("alerts.js");
expect(panel.textContent).toContain("+12");
expect(panel.textContent).toContain("−3");
// Totals across the listed files.
expect(panel.textContent).toContain("+13");
// An untracked file has no delta to report and says so rather than "+0".
expect(panel.textContent).toContain("new");
expect(panel.textContent).toContain("3 files changed");
// Closing is delayed so the pointer can travel into the panel and scroll
// it — the panel is portalled to the body, away from its trigger.
fireEvent.mouseLeave(counts);
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
});
it("stays open while the pointer is inside the panel", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
fireEvent.mouseLeave(counts);
// The pointer landed on the panel before the pending close fired.
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
await new Promise((r) => setTimeout(r, 200));
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
});
it("says how many files the server-side cap left out", async () => {
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
render(<Harness laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
});
it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<Harness laneId={1} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).not.toContain("modified");
});
it("renders nothing and no error when git is unavailable", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
render(<Harness laneId={2} />);
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
expect(screen.queryByTestId("lane-git-header")).toBeNull();
});
it("swallows a rejected request instead of surfacing an error", async () => {
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
render(<Harness laneId={3} />);
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
expect(screen.queryByTestId("lane-git")).toBeNull();
expect(screen.queryByText(/network down/)).toBeNull();
});
it("stops polling once the panel unmounts", async () => {
vi.useFakeTimers();
try {
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
const { unmount } = render(<Harness laneId={1} />);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
unmount();
vi.advanceTimersByTime(120_000);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
+33 -3
View File
@@ -27,7 +27,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api";
@@ -47,6 +47,20 @@ import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
/** The claude lane a session's cwd belongs to: the longest path-boundary
* prefix, the same rule the server binds hook sessions with. External
* sessions are all claude — Codex emits no hooks. */
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
let best: Lane | null = null;
for (const l of lanes) {
if (l.tool !== "claude") continue;
const root = l.cwd.replace(/\/+$/, "");
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
}
return best?.id ?? null;
}
export interface LaneConsolePaneProps {
lanes: Lane[];
laneId: number | null;
@@ -97,6 +111,22 @@ export function LaneConsolePane({
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude";
// The page polls runs and sessions for EVERY lane; this pane's Active runs
// list is this lane's only. No lane selected keeps the full lists.
const laneRuns = useMemo(
() =>
laneId === null || !activeRuns
? activeRuns
: { ...activeRuns, items: activeRuns.items.filter((r) => r.laneId === laneId) },
[activeRuns, laneId]
);
const laneSessions = useMemo(
() =>
laneId === null
? externalSessions
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
useEffect(() => {
@@ -487,11 +517,11 @@ export function LaneConsolePane({
</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}
activeRuns={laneRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
runHistory={runHistory}
externalSessions={externalSessions}
externalSessions={laneSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onKill={killRun}
+2 -2
View File
@@ -58,7 +58,7 @@ function StatusPill({
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
@@ -574,7 +574,7 @@ function UnifiedRunRowView({
)}
{row.external && (
<span
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
title={t("runs.externalHint")}
>
{t("runs.externalBadge")}
+273 -16
View File
@@ -53,30 +53,214 @@ import type {
RunStartArgs,
CodexSandbox,
CodexApproval,
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { Select } from "../Select";
// Minimal SlashCommand type (from deleted RunConsole)
export interface SlashCommand {
name: string;
source: "project" | "user" | "plugin" | "builtin";
source: "project" | "user" | "builtin";
description?: string;
}
// Minimal PromptEditor component (from deleted RunConsole)
// Commands the CLI answers itself. They are typed into the pane like any
// other text, so they do run — they are just not files anyone can list.
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
"help",
"clear",
"compact",
"config",
"model",
"memory",
"hooks",
"cost",
"agents",
"review",
"permissions",
"status",
"init",
"mcp",
"plugin",
"output-style",
"exit",
].map((name) => ({ name, source: "builtin" }));
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
project: "text-status-success border-status-success/40",
user: "text-accent border-accent/40",
builtin: "text-fg-muted border-border",
};
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
* description (3+ chars only, so one keystroke drags nothing tangential in). */
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
if (!q) return 1;
const n = name.toLowerCase();
if (n === q) return 1000;
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
const idx = n.indexOf(q);
if (idx >= 0) return 400 - Math.min(idx, 100);
let i = 0;
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
if (i === q.length) return 200;
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
return 0;
}
interface Trigger {
kind: "slash" | "file";
query: string;
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
start: number;
cursor: number;
}
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
function detectTrigger(value: string, cursor: number): Trigger | null {
let start = cursor;
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
const tok = value.slice(start, cursor);
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
return null;
}
interface PromptEditorProps {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
rows?: number;
slashCommands: SlashCommand[];
/** Offer slash commands. Off for codex: these are Claude Code's. */
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string;
}
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
function PromptEditor({
value,
onChange,
onSubmit,
placeholder,
rows = 5,
slash,
fileCwd,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
if (commandsCwd !== fileCwd) {
setCommandsCwd(fileCwd);
setCommands(null);
}
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
// Commands and skills are read the first time `/` is typed for this cwd —
// two directory walks nobody should pay for on every render of the form.
useEffect(() => {
if (kind !== "slash" || commands) return;
let live = true;
const toCmd = (c: CcMdItem): SlashCommand => ({
name: c.name,
source: c.scope,
description: c.frontmatter?.description || c.preview.slice(0, 80),
});
Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
]).then(([c, sk]) => {
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
});
return () => {
live = false;
};
}, [kind, commands, fileCwd]);
// Files: debounced, and only the newest query may land.
const query = trigger?.query ?? "";
useEffect(() => {
if (kind !== "file") return;
let live = true;
const tid = window.setTimeout(() => {
api.run
.files(fileCwd, query)
.then((r) => live && setFiles(r.items))
.catch(() => live && setFiles([]));
}, 120);
return () => {
live = false;
window.clearTimeout(tid);
};
}, [kind, query, fileCwd]);
const slashItems = useMemo(() => {
if (kind !== "slash" || !commands) return [];
const q = query.toLowerCase();
return commands
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
.filter((x) => x.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
a.cmd.name.localeCompare(b.cmd.name)
)
.map((x) => x.cmd);
}, [kind, commands, query]);
const count = kind === "file" ? files.length : slashItems.length;
const current = Math.min(active, Math.max(0, count - 1));
const pick = (token: string) => {
if (!trigger) return;
const before = value.slice(0, trigger.start);
const after = value.slice(trigger.cursor);
const sep = after.startsWith(" ") ? "" : " ";
onChange(before + token + sep + after);
setTrigger(null);
setActive(0);
const pos = before.length + token.length + 1;
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.setSelectionRange(pos, pos);
});
};
const pickAt = (i: number) => {
if (kind === "file") {
const f = files[i];
if (f) pick(`@${f}`);
} else {
const c = slashItems[i];
if (c) pick(`/${c.name}`);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const step = e.key === "ArrowDown" ? 1 : -1;
setActive(Math.min(count - 1, Math.max(0, current + step)));
return;
}
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
pickAt(current);
return;
}
}
if (kind && e.key === "Escape") {
e.preventDefault();
setTrigger(null);
return;
}
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
@@ -85,15 +269,89 @@ function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: Prom
}
};
const retrack = (ta: HTMLTextAreaElement) => {
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
};
const rowClass = (i: number) =>
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
<div className="relative">
<textarea
ref={taRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
{kind && (
<div
role="listbox"
data-testid="prompt-autocomplete"
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
>
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
</div>
{count === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
</div>
) : kind === "slash" ? (
slashItems.map((c, i) => (
<button
key={`${c.source}:${c.name}`}
type="button"
role="option"
aria-selected={i === current}
// Keep focus in the textarea, or its blur closes the list first.
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={rowClass(i)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
>
{c.source}
</span>
</div>
{c.description && (
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
)}
</button>
))
) : (
files.map((f, i) => (
<button
key={f}
type="button"
role="option"
aria-selected={i === current}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
>
{f}
</button>
))
)}
</div>
)}
</div>
);
}
@@ -122,7 +380,6 @@ interface RunSetupProps {
* sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */
laneCwd?: string;
slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
/** The selected lane's CLI. Decides which advanced fields render. */
@@ -287,7 +544,7 @@ export function RunSetup(props: RunSetupProps) {
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
rows={5}
slashCommands={props.slashCommands ?? []}
slash={props.tool !== "codex"}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
@@ -691,7 +948,7 @@ function SessionPicker({
s.status === "active"
? "bg-status-success/10 text-status-success border-status-success/30"
: s.status === "completed"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: s.status === "error"
? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-fg-secondary border-border"
+28 -2
View File
@@ -9,13 +9,17 @@
* The socket auto-reconnects: a server restart kills the attach PTY, and
* without a retry the pane silently freezes on its last painted frame — it
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
interface TerminalViewProps {
runId: string;
@@ -33,6 +37,14 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
// re-attach on each one.
const onExitRef = useRef(onExit);
onExitRef.current = onExit;
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
// assumes a dark background. Read through a ref at construction so a theme
// change never re-runs the attach effect; the effect below recolours live.
const darkTheme = useTheme().themeFor.dark;
const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]);
const paletteRef = useRef(palette);
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -56,7 +68,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current,
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
if (containerRef.current) term.open(containerRef.current);
@@ -225,8 +239,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
ws?.close();
canvas?.dispose();
term.dispose();
termRef.current = null;
};
}, [runId, wsBaseUrl]);
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
useEffect(() => {
if (termRef.current) termRef.current.options.theme = palette;
}, [palette]);
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
);
}
@@ -331,4 +331,56 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
const run = (id: string, laneId: number, promptPreview: string) => ({
id,
laneId,
status: "running",
cwd: "/workspace/x",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
});
const session = (id: string, cwd: string, name: string) =>
({
id,
cwd,
name,
model: null,
source: "local",
started_at: "2026-09-24T01:00:00Z",
}) as never;
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
lanes={[
LANE,
{ ...LANE, id: 2, cwd: "/workspace/b" },
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
]}
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
externalSessions={[
session("s1", "/workspace/a/src", "my session"),
session("s2", "/workspace/b", "b session"),
session("s3", "/workspace/a/nested/x", "nested session"),
session("s4", "/workspace/ab", "sibling prefix"),
]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.getByText("my session")).toBeTruthy();
expect(screen.queryByText("other lane's")).toBeNull();
expect(screen.queryByText("b session")).toBeNull();
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
});
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { RunSetup } from "../RunSetup";
import type { CwdSuggestion } from "../../../lib/api";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
api: {
run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
ccConfig: { file: r({ text: "" }) },
ccConfig: {
file: r({ text: "" }),
commands: r({
items: [
{
scope: "project",
name: "ship-it",
frontmatter: { description: "Ship the change" },
preview: "",
},
],
}),
skills: r({
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
}),
},
},
};
});
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
busy={false}
activeRuns={null}
resumeSession={null}
slashCommands={[]}
runHistory={[]}
tool="claude"
sandbox="workspace-write"
@@ -300,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
busy={false}
activeRuns={null}
resumeSession={null}
slashCommands={[]}
runHistory={[]}
laneCwd="/Users/tester/lane-b"
tool="claude"
@@ -364,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
expect(onResumeLast).toHaveBeenCalled();
});
});
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
fireEvent.change(box(), { target: { value: "/rev" } });
// Both prefix matches; the shorter name ranks first.
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual([
expect.stringContaining("/review"),
expect.stringContaining("/review-pr"),
]);
fireEvent.keyDown(box(), { key: "ArrowDown" });
// Read for the directory the run will start in, not the server's own.
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
fireEvent.keyDown(box(), { key: "Enter" });
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
// Enter picked the suggestion; it did not send the prompt.
expect(spies.onStart).not.toHaveBeenCalled();
});
it("suggests files under the cwd on @", async () => {
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
// The prompt is controlled by the page: put the cursor at the end of it.
const { spies } = renderSetup({ prompt: "look at @ma" });
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
fireEvent.select(box());
fireEvent.click(await screen.findByText("src/main.ts"));
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
});
it("offers no slash commands on a codex lane", async () => {
renderSetup({ prompt: "", tool: "codex" });
fireEvent.change(box(), { target: { value: "/rev" } });
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
expect(api.ccConfig.commands).not.toHaveBeenCalled();
});
});
@@ -3,11 +3,12 @@
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* re-attaches after the socket drops (server restart) but not after `exit`.
* re-attaches after the socket drops (server restart) but not after `exit`,
* and recolours live on a dark-theme change without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render, cleanup, act } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
const writeMock = vi.fn();
@@ -26,9 +27,13 @@ const termState = {
rows: 28,
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn().mockImplementation(() => ({
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
...(ctorArgs.push(opts), {}),
options: termOptions,
get modes() {
return termState.modes;
},
@@ -58,6 +63,10 @@ vi.mock("@xterm/xterm", () => ({
focus: focusMock,
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
vi.mock("../../../lib/terminalTheme", () => ({
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
@@ -103,6 +112,10 @@ describe("TerminalView", () => {
loadAddonMock.mockClear();
openMock.mockClear();
focusMock.mockClear();
disposeMock.mockClear();
ctorArgs.length = 0;
delete termOptions.theme;
localStorage.clear();
});
afterEach(cleanup);
@@ -126,6 +139,21 @@ describe("TerminalView", () => {
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
});
it("paints the chosen DARK theme and recolours live without re-attaching", () => {
localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark
localStorage.setItem("agenthub.theme.dark", "apple-dark");
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" });
act(() => {
localStorage.setItem("agenthub.theme.dark", "darcula");
window.dispatchEvent(new Event("agenthub-theme"));
});
expect(termOptions.theme).toEqual({ background: "bg-darcula" });
expect(MockWebSocket.instances).toHaveLength(1);
expect(disposeMock).not.toHaveBeenCalled();
});
it("writes incoming WS data to the terminal", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
@@ -0,0 +1,128 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance: the Dark / Light / System mode switch and
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Palette, Sun } from "lucide-react";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
"intellij-light": "IntelliJ Light",
canvas: "Canvas",
};
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "dark", Icon: Moon },
{ mode: "light", Icon: Sun },
{ mode: "system", Icon: Monitor },
];
function Swatch({ mode, name }: { mode: Theme; name: string }) {
return (
<div
aria-hidden
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
data-mode={mode}
data-theme={name === "default" ? undefined : name}
>
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-accent" />
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
</div>
<div className="font-mono text-[10px] leading-tight">
<span className="text-syntax-keyword">const</span>{" "}
<span className="text-syntax-function">x</span>{" "}
<span className="text-syntax-operator">=</span>{" "}
<span className="text-syntax-string">"ok"</span>
</div>
<div className="flex gap-1">
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
</div>
</div>
</div>
);
}
export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
return (
<section id="appearance" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Palette className="w-4 h-4 text-fg-muted" />
{t("appearance.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
<div className="card p-5 space-y-5">
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{MODES.map(({ mode: m, Icon }) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
aria-pressed={mode === m}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
mode === m
? "bg-accent/20 text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</button>
))}
</div>
</div>
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</section>
);
}
@@ -0,0 +1,60 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AppearanceSettings } from "../AppearanceSettings";
const root = document.documentElement;
describe("AppearanceSettings", () => {
beforeEach(() => {
localStorage.clear();
root.classList.remove("dark");
root.removeAttribute("data-theme");
root.removeAttribute("data-mode");
});
it("renders every theme of both modes, defaults selected", () => {
render(<AppearanceSettings />);
const pressed = screen
.getAllByRole("button", { pressed: true })
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
});
it("picking a dark theme applies it at once; a light pick waits for light mode", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /Darcula/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /Canvas/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /^Light$/ }));
expect(root.classList.contains("dark")).toBe(false);
expect(root.dataset.theme).toBe("canvas");
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
});
it("the System mode button is pressed after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /System/ }));
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
it("each swatch scopes its own mode and theme", () => {
const { container } = render(<AppearanceSettings />);
expect(container.querySelector('.dark[data-theme="darcula"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
});
});
@@ -151,7 +151,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x2", innerW)
.attr("y1", (d) => yScale(d))
.attr("y2", (d) => yScale(d))
.attr("stroke", "#2a2a3d")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1);
// Y axis (sessions)
@@ -166,7 +166,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
)
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -176,7 +176,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#9ca3af")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -185,7 +185,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", innerW / 2)
.attr("y", innerH + 38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -196,7 +196,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", -innerH / 2)
.attr("y", -38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -229,7 +229,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", bx + bw / 2)
.attr("y", by - 5)
.attr("text-anchor", "middle")
.attr("fill", "#a5b4fc")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
@@ -242,7 +242,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("y", innerH - 1)
.attr("width", bw)
.attr("height", 1)
.attr("fill", "#2a2a3d");
.attr("fill", "rgb(var(--border))");
}
// Transparent, full-height hover target on top of the bar.
@@ -364,12 +364,12 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
label={t("compaction.sessionsAffected")}
value={affected.toLocaleString()}
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
accent="text-violet-300"
accent="text-violet-700 dark:text-violet-300"
/>
<StatBox
label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)}
accent="text-blue-400"
accent="text-blue-700 dark:text-blue-400"
/>
<StatBox
label={t("compaction.peakSession")}
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#10b981"
stroke="rgb(var(--status-success))"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
@@ -174,7 +174,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
</div>
<span
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
style={{ color: isHovered ? color : "#9ca3af" }}
style={{ color: isHovered ? color : "rgb(var(--fg-secondary))" }}
>
{d.count}
</span>
@@ -587,7 +587,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("x", (_, i) => layerXPositions[i] ?? 0)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "10px")
.attr("font-weight", "500")
.attr("letter-spacing", "0.08em")
@@ -605,7 +605,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("y1", 32)
.attr("x2", sepX)
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
.attr("stroke", "#1f1f30")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
}
@@ -651,7 +651,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
@@ -262,7 +262,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
// Grid lines
const gridColor = "#2a2a3d";
const gridColor = "rgb(var(--border))";
g.append("g")
.attr("class", "grid-x")
@@ -303,9 +303,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.tickFormat((d) => fmtXTick(d as number))
)
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// X axis label
@@ -313,7 +313,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", innerW / 2)
.attr("y", innerH + 44)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.duration"));
@@ -321,9 +321,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
g.append("g")
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// Y axis label
@@ -332,7 +332,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", -innerH / 2)
.attr("y", -40)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.agentCount"));
@@ -75,7 +75,7 @@ type AgentNode = SessionDrillInData["tree"][number];
function statusColor(status: string): string {
switch (status) {
case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
return "text-violet-700 dark:text-violet-400 bg-violet-500/10 border-violet-500/20";
case "working":
return "text-status-success bg-status-success/10 border-status-success/20";
case "error":
@@ -83,7 +83,7 @@ function statusColor(status: string): string {
case "active":
return "text-status-success bg-status-success/10 border-status-success/20";
case "waiting":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
return "text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
default:
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
}
@@ -186,7 +186,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
{/* Name */}
<span
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
className={`text-sm font-medium truncate ${isMain ? "text-indigo-700 dark:text-indigo-300" : "text-fg-secondary"}`}
>
{node.name}
</span>
@@ -255,7 +255,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
>
{/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
{ev.tool_name ?? ev.event_type}
</span>
@@ -286,8 +286,8 @@ interface EventSequenceProps {
const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-500",
tool_result: "text-status-success",
agent_start: "text-indigo-400",
agent_stop: "text-violet-400",
agent_start: "text-indigo-700 dark:text-indigo-400",
agent_stop: "text-violet-700 dark:text-violet-400",
compaction: "text-status-warning",
error: "text-status-danger",
};
@@ -121,13 +121,13 @@ function statusClass(status: string): string {
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
// as its own color in both the filter row and the result label chips.
const PHASE_PALETTE = [
"bg-violet-500/15 text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-300 border-sky-500/40",
"bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/40",
"bg-status-warning/15 text-status-warning border-status-warning/40",
"bg-status-success/15 text-status-success border-status-success/40",
"bg-rose-500/15 text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
"bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/40",
];
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
@@ -321,7 +321,7 @@ export function WorkflowRunsPanel({
if (!controlled && loading) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
<Loader2 className="w-4 h-4 animate-spin text-violet-700 dark:text-violet-400" />
<span className="animate-pulse">{t("runs.loading")}</span>
</div>
);
@@ -365,7 +365,7 @@ export function WorkflowRunsPanel({
{running ? (
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
) : (
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@@ -393,7 +393,7 @@ export function WorkflowRunsPanel({
<Link
to={`/sessions/${encodeURIComponent(run.session_id)}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
className="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
title={t("runs.openSession")}
>
<ExternalLink className="w-3.5 h-3.5" />
@@ -565,7 +565,7 @@ export function WorkflowRunsPanel({
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
{ts?.loading && (
<span className="flex items-center gap-1 text-violet-400">
<span className="flex items-center gap-1 text-violet-700 dark:text-violet-400">
<Loader2 className="w-3 h-3 animate-spin" />
{t("runs.loadingFull")}
</span>
@@ -71,7 +71,7 @@ function formatDurationSec(sec: number): string {
function successRateColor(rate: number): string {
if (rate > 90) return "text-status-success";
if (rate > 70) return "text-yellow-400";
if (rate > 70) return "text-yellow-700 dark:text-yellow-400";
return "text-status-danger";
}
@@ -325,7 +325,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgAgentDepth")}
value={stats.avgDepth.toFixed(1)}
icon={GitFork}
accentClass="text-indigo-400"
accentClass="text-indigo-700 dark:text-indigo-400"
calculationKey="stats.tooltip.calc.depth"
interp={interpAvgDepth(stats.avgDepth)}
metricPhraseKey="stats.tooltip.phrase.depth"
@@ -352,7 +352,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.mostCommonFlow")}
value={topFlowLabel}
icon={ArrowRightLeft}
accentClass="text-violet-400"
accentClass="text-violet-700 dark:text-violet-400"
calculationKey="stats.tooltip.calc.topFlow"
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
metricPhraseKey="stats.tooltip.phrase.topFlow"
@@ -361,7 +361,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgCompactions")}
value={stats.avgCompactions.toFixed(1)}
icon={Layers}
accentClass="text-cyan-400"
accentClass="text-cyan-700 dark:text-cyan-400"
calculationKey="stats.tooltip.calc.compactions"
interp={interpAvgCompactions(stats.avgCompactions)}
metricPhraseKey="stats.tooltip.phrase.compactions"
+166 -2
View File
@@ -1,10 +1,10 @@
/**
* @file useTheme.test.ts
* @description Tests for useTheme — the dark/light mode hook.
* @description Tests for useTheme — the dark/light/system mode and per-mode named-theme hook.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTheme } from "../useTheme";
@@ -12,9 +12,12 @@ describe("useTheme", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-mode");
});
afterEach(() => {
document.documentElement.classList.remove("dark");
vi.unstubAllGlobals();
});
it("defaults to dark when localStorage is empty", () => {
@@ -46,4 +49,165 @@ describe("useTheme", () => {
expect(result.current.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("system mode follows prefers-color-scheme, live", () => {
let dark = true;
const listeners = new Set<() => void>();
vi.stubGlobal("matchMedia", (query: string) => ({
get matches() {
return dark;
},
media: query,
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
}));
const { result } = renderHook(() => useTheme());
act(() => result.current.setMode("system"));
expect(result.current.mode).toBe("system");
expect(result.current.theme).toBe("dark");
expect(localStorage.getItem("theme")).toBe("system");
act(() => {
dark = false;
listeners.forEach((fn) => fn());
});
expect(result.current.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(document.documentElement.dataset.mode).toBe("light");
});
it("system mode without matchMedia falls back to dark", () => {
vi.stubGlobal("matchMedia", undefined);
localStorage.setItem("theme", "system");
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
});
it("each mode remembers its own named theme", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("light", "canvas"));
expect(document.documentElement.dataset.theme).toBe("darcula");
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
act(() => result.current.toggleTheme());
expect(result.current.theme).toBe("light");
expect(document.documentElement.dataset.theme).toBe("canvas");
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "canvas" });
});
it("the default theme carries no data-theme attribute", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("dark", "default"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("an unknown stored theme id falls back to default", () => {
localStorage.setItem("agenthub.theme.dark", "no-such-theme");
const { result } = renderHook(() => useTheme());
expect(result.current.themeFor.dark).toBe("default");
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("a light theme id is never applied in dark mode", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "canvas"));
expect(result.current.themeFor.dark).toBe("default");
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("two hook instances stay in sync", () => {
const a = renderHook(() => useTheme());
const b = renderHook(() => useTheme());
act(() => a.result.current.setTheme("light"));
act(() => a.result.current.setThemeFor("light", "apple-light"));
expect(b.result.current.theme).toBe("light");
expect(b.result.current.themeFor.light).toBe("apple-light");
});
it("another tab's storage write updates a mounted hook", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
act(() => {
localStorage.setItem("theme", "light");
localStorage.setItem("agenthub.theme.light", "canvas");
window.dispatchEvent(new StorageEvent("storage", { key: "theme" }));
});
expect(result.current.theme).toBe("light");
expect(document.documentElement.dataset.theme).toBe("canvas");
expect(document.documentElement.dataset.mode).toBe("light");
});
it("disabled storage (throwing) falls back to dark/default and setters do not crash", () => {
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
try {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
expect(result.current.mode).toBe("dark");
expect(result.current.themeFor).toEqual({ dark: "default", light: "default" });
expect(() => act(() => result.current.setMode("light"))).not.toThrow();
expect(() => act(() => result.current.setThemeFor("dark", "darcula"))).not.toThrow();
expect(() => act(() => result.current.toggleTheme())).not.toThrow();
expect(result.current.theme).toBe("dark");
} finally {
get.mockRestore();
set.mockRestore();
}
});
it("setThemeFor with an unknown id keeps the previous pick", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("dark", "no-such-theme"));
act(() => result.current.setThemeFor("light", ""));
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "default" });
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
expect(localStorage.getItem("agenthub.theme.light")).toBeNull();
expect(document.documentElement.dataset.theme).toBe("darcula");
});
it("an unknown stored mode falls back to dark", () => {
localStorage.setItem("theme", "blue");
const { result } = renderHook(() => useTheme());
expect(result.current.mode).toBe("dark");
expect(result.current.theme).toBe("dark");
expect(document.documentElement.dataset.mode).toBe("dark");
});
it.each([
[true, "light"],
[false, "dark"],
])("toggle from system (OS dark=%s) sets the explicit opposite: %s", (osDark, expected) => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: osDark,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
localStorage.setItem("theme", "system");
const { result } = renderHook(() => useTheme());
expect(result.current.mode).toBe("system");
act(() => result.current.toggleTheme());
expect(result.current.mode).toBe(expected);
expect(result.current.theme).toBe(expected);
expect(localStorage.getItem("theme")).toBe(expected);
});
it("unmount removes the matchMedia listener", () => {
const listeners = new Set<() => void>();
vi.stubGlobal("matchMedia", (query: string) => ({
matches: true,
media: query,
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
}));
const { unmount } = renderHook(() => useTheme());
expect(listeners.size).toBeGreaterThan(0);
unmount();
expect(listeners.size).toBe(0);
});
});
+99 -24
View File
@@ -1,60 +1,135 @@
/**
* @file useTheme.ts
* @description Dark/light mode state, backed by `localStorage` and the `dark`
* class on `<html>` that Tailwind's `darkMode: "class"` reads. Default is
* dark; there is no `prefers-color-scheme` fallback.
* @description Appearance state: a mode (`dark` | `light` | `system`) plus one
* named theme remembered PER mode, all backed by `localStorage`. Applies the
* `dark` class Tailwind's `darkMode: "class"` reads, `data-mode`, and a
* `data-theme` attribute (absent for `default`) on `<html>`; the palettes
* themselves are CSS-variable blocks in `index.css`. State is never cached in
* the module — every instance reads storage + `matchMedia` through
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
* another tab's change arrives through the `storage` event.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
export type Theme = "dark" | "light";
export type ThemeMode = Theme | "system";
const STORAGE_KEY = "theme";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark"],
light: ["default", "apple-light", "intellij-light", "canvas"],
} as const satisfies Record<Theme, readonly string[]>;
function readStoredTheme(): Theme {
const MODE_KEY = "theme";
const themeKey = (mode: Theme) => `agenthub.theme.${mode}`;
const CHANGE_EVENT = "agenthub-theme";
const SYSTEM_QUERY = "(prefers-color-scheme: dark)";
function read(key: string): string | null {
try {
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
return localStorage.getItem(key);
} catch {
return "dark";
return null;
}
}
function writeStoredTheme(theme: Theme): void {
function write(key: string, value: string): void {
try {
localStorage.setItem(STORAGE_KEY, theme);
localStorage.setItem(key, value);
} catch {
/* ignore quota / disabled storage */
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}
function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle("dark", theme === "dark");
function systemQuery(): MediaQueryList | undefined {
return typeof window.matchMedia === "function" ? window.matchMedia(SYSTEM_QUERY) : undefined;
}
function readMode(): ThemeMode {
const v = read(MODE_KEY);
return v === "light" || v === "system" ? v : "dark";
}
function readThemeFor(mode: Theme): string {
const v = read(themeKey(mode));
return v && (THEMES[mode] as readonly string[]).includes(v) ? v : "default";
}
// A primitive snapshot: stable by value, so useSyncExternalStore never loops.
function getSnapshot(): string {
const mode = readMode();
const resolved: Theme =
mode === "system" ? (systemQuery()?.matches === false ? "light" : "dark") : mode;
return [mode, resolved, readThemeFor("dark"), readThemeFor("light")].join("|");
}
function subscribe(onChange: () => void): () => void {
const mq = systemQuery();
window.addEventListener(CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
mq?.addEventListener?.("change", onChange);
return () => {
window.removeEventListener(CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
mq?.removeEventListener?.("change", onChange);
};
}
function apply(resolved: Theme, name: string): void {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.dataset.mode = resolved;
if (name === "default") root.removeAttribute("data-theme");
else root.dataset.theme = name;
}
/**
* Read/write the dashboard's active color theme.
* @returns the current theme, a setter for an explicit value, and a toggle.
* Read/write the dashboard's appearance.
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
* the user picked; `themeFor` — the named theme each mode remembers; setters
* for each, and `toggleTheme`, which sets the explicit opposite of `theme`.
*/
export function useTheme(): {
theme: Theme;
mode: ThemeMode;
themeFor: Record<Theme, string>;
setTheme: (theme: Theme) => void;
setMode: (mode: ThemeMode) => void;
setThemeFor: (mode: Theme, name: string) => void;
toggleTheme: () => void;
} {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
const [mode, resolved, dark, light] = useSyncExternalStore(subscribe, getSnapshot).split("|") as [
ThemeMode,
Theme,
string,
string,
];
const active = resolved === "dark" ? dark : light;
useEffect(() => {
applyTheme(theme);
}, [theme]);
useLayoutEffect(() => {
apply(resolved, active);
}, [resolved, active]);
const setTheme = useCallback((next: Theme) => {
writeStoredTheme(next);
setThemeState(next);
const setMode = useCallback((next: ThemeMode) => write(MODE_KEY, next), []);
const setThemeFor = useCallback((forMode: Theme, name: string) => {
if ((THEMES[forMode] as readonly string[]).includes(name)) write(themeKey(forMode), name);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
setMode(resolved === "dark" ? "light" : "dark");
}, [resolved, setMode]);
return { theme, setTheme, toggleTheme };
return {
theme: resolved,
mode,
themeFor: { dark, light },
setTheme: setMode,
setMode,
setThemeFor,
toggleTheme,
};
}
+11 -12
View File
@@ -25,15 +25,16 @@
"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.",
"addLaneBranchHintAuto": "Leave empty to let the server name the branch from the title.",
"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",
"addLaneBranchPlaceholder": "Automatic — named from the title",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
"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.",
@@ -109,6 +110,8 @@
"changes.size.compact": "Narrow",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.size.full": "Full width",
"changes.stageAll": "Stage all ({{count}})",
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
@@ -168,19 +171,12 @@
"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.errNoRemote": "No remote to push to or pull from. Add one, or set this branch's upstream, in a terminal.",
"gitBar.inSync": "in sync",
"gitBar.newLane": "New lane here",
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
"gitBar.noUpstream": "not pushed",
"gitBar.pull": "Pull",
@@ -238,5 +234,8 @@
"title": "Lanes",
"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"
"worktrees.heading_other": "{{count}} worktrees",
"changes.stageHunk": "Stage this hunk",
"changes.undoHunk": "Undo this hunk",
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone"
}
+2 -1
View File
@@ -54,7 +54,8 @@
"theme": "Theme",
"themeNames": {
"dark": "Dark",
"light": "Light"
"light": "Light",
"system": "System"
},
"throughput60s": "Throughput · last 60s",
"topEventTypes": "Top event types",
+14
View File
@@ -495,5 +495,19 @@
"testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only.",
"mode": "Mode",
"modes": {
"dark": "Dark",
"light": "Light",
"system": "System"
},
"darkThemes": "Dark themes",
"lightThemes": "Light themes",
"active": "· in use now",
"default": "Default"
}
}
+11 -12
View File
@@ -25,15 +25,16 @@
"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.",
"addLaneBranchHintAuto": "Để trống để server tự đặt tên nhánh theo tiêu đề.",
"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",
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
"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.",
@@ -109,6 +110,8 @@
"changes.size.compact": "Hẹp",
"changes.size.half": "50 / 50",
"changes.size.wide": "70 / 30",
"changes.size.full": "Toàn màn hình",
"changes.stageAll": "Stage tất cả ({{count}})",
"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.",
@@ -168,19 +171,12 @@
"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.errNoRemote": "Không có remote để push hoặc pull. Hãy thêm remote, hoặc đặt upstream cho nhánh này, trong terminal.",
"gitBar.inSync": "đã đồng bộ",
"gitBar.newLane": "Tạo lane mới",
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
"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",
@@ -238,5 +234,8 @@
"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",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree"
"worktrees.heading_other": "{{count}} worktree",
"changes.stageHunk": "Stage đoạn này",
"changes.undoHunk": "Hoàn tác đoạn này",
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục"
}
+2 -1
View File
@@ -54,7 +54,8 @@
"theme": "Giao diện",
"themeNames": {
"dark": "Tối",
"light": "Sáng"
"light": "Sáng",
"system": "Theo hệ thống"
},
"throughput60s": "Lưu lượng · 60s qua",
"topEventTypes": "Loại sự kiện phổ biến",
+14
View File
@@ -495,5 +495,19 @@
"testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}"
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
"mode": "Chế độ",
"modes": {
"dark": "Tối",
"light": "Sáng",
"system": "Theo hệ thống"
},
"darkThemes": "Theme tối",
"lightThemes": "Theme sáng",
"active": "· đang dùng",
"default": "Mặc định"
}
}
+176 -5
View File
@@ -24,8 +24,11 @@
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
*/
:root {
:root,
[data-mode="light"] {
/* Light theme — the CSS default, so it applies with no class on <html>.
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
page (the Settings appearance cards).
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
as it does in dark: the page is the DARKEST step and a card is brighter
than the page it sits on. The previous light block used the scale in its
@@ -73,7 +76,9 @@
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
--tint-base: 245 243 236; /* = surface-3 */
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
--tint-base: var(--surface-3);
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
@@ -87,7 +92,7 @@
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
--syn-plain: 31 30 27; /* = fg-primary */
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
}
@@ -116,7 +121,7 @@
--status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
--tint-base: 39 42 45; /* = surface-3 */
--tint-base: var(--surface-3);
/* Unchanged from the pastels this file has always drawn code in. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
@@ -128,11 +133,177 @@
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
--syn-tag: 253 164 175; /* rose-300 #fda4af */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
--syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */
}
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
* Settings → Appearance). Each overrides only what differs from its mode's
* default block above; `--tint-base` and light `--syn-plain` are derived there.
* Dark ones pair with `.dark`, light ones with `[data-mode="light"]`, so a
* theme can never paint over the wrong mode whatever order the attributes
* land in. Contrast is held to the defaults' own bar: fg-muted ≥4.5:1 on a
* light card, status labels ≥4.5:1 on their own /20 chip in light themes. */
.dark[data-theme="darcula"] {
--surface-0: 30 31 32; /* #1e1f20 */
--surface-1: 35 36 37; /* #232425 */
--surface-2: 39 41 42; /* #27292a */
--surface-3: 43 43 43; /* #2b2b2b */
--surface-4: 51 53 55; /* #333537 */
--surface-5: 60 63 65; /* #3c3f41 */
--border: 63 66 68; /* #3f4244 */
--border-light: 77 81 83; /* #4d5153 */
--accent: 53 146 196; /* #3592c4 */
--accent-hover: 74 163 214; /* #4aa3d6 */
--fg-primary: 220 220 220; /* #dcdcdc */
--fg-secondary: 169 183 198; /* #a9b7c6 */
--fg-muted: 140 146 150; /* #8c9296 */
--status-success: 126 194 135; /* #7ec287 */
--status-danger: 255 143 140; /* #ff8f8c */
--status-warning: 232 191 106; /* #e8bf6a */
--syn-comment: 128 128 128; /* #808080 */
--syn-string: 127 159 105; /* #7f9f69 */
--syn-number: 104 151 187; /* #6897bb */
--syn-keyword: 204 120 50; /* #cc7832 */
--syn-builtin: 136 136 198; /* #8888c6 */
--syn-function: 255 198 109; /* #ffc66d */
--syn-operator: 169 183 198; /* #a9b7c6 */
--syn-punctuation: 169 183 198; /* #a9b7c6 */
--syn-property: 168 139 192; /* #a88bc0 */
--syn-tag: 232 191 106; /* #e8bf6a */
--syn-add: 106 171 115; /* #6aab73 */
--syn-del: 255 107 104; /* #ff6b68 */
--syn-plain: 169 183 198; /* #a9b7c6 */
}
.dark[data-theme="apple-dark"] {
--surface-0: 22 22 24; /* #161618 */
--surface-1: 28 28 30; /* #1c1c1e */
--surface-2: 36 36 38; /* #242426 */
--surface-3: 44 44 46; /* #2c2c2e */
--surface-4: 54 54 56; /* #363638 */
--surface-5: 58 58 60; /* #3a3a3c */
--border: 56 56 58; /* #38383a */
--border-light: 72 72 74; /* #48484a */
--accent: 10 132 255; /* #0a84ff */
--accent-hover: 64 156 255; /* #409cff */
--fg-primary: 245 245 247; /* #f5f5f7 */
--fg-secondary: 174 174 178; /* #aeaeb2 */
--fg-muted: 142 142 147; /* #8e8e93 */
--status-success: 48 209 88; /* #30d158 */
--status-danger: 255 140 134; /* #ff8c86 */
--status-warning: 255 214 10; /* #ffd60a */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
--syn-number: 208 191 105; /* #d0bf69 */
--syn-keyword: 252 95 163; /* #fc5fa3 */
--syn-builtin: 93 216 255; /* #5dd8ff */
--syn-function: 103 183 164; /* #67b7a4 */
--syn-operator: 161 161 166; /* #a1a1a6 */
--syn-punctuation: 161 161 166; /* #a1a1a6 */
--syn-property: 181 140 240; /* #b58cf0 */
--syn-tag: 252 95 163; /* #fc5fa3 */
--syn-add: 48 209 88; /* #30d158 */
--syn-del: 255 105 97; /* #ff6961 */
--syn-plain: 229 229 234; /* #e5e5ea */
}
[data-mode="light"][data-theme="apple-light"] {
--surface-0: 233 233 238; /* #e9e9ee */
--surface-1: 239 239 244; /* #efeff4 */
--surface-2: 245 245 248; /* #f5f5f8 */
--surface-3: 251 251 253; /* #fbfbfd */
--surface-4: 253 253 254; /* #fdfdfe */
--surface-5: 255 255 255; /* #ffffff */
--border: 209 209 214; /* #d1d1d6 */
--border-light: 229 229 234; /* #e5e5ea */
--accent: 0 102 204; /* #0066cc */
--accent-hover: 0 85 179; /* #0055b3 */
--fg-primary: 29 29 31; /* #1d1d1f */
--fg-secondary: 81 81 84; /* #515154 */
--fg-muted: 110 110 115; /* #6e6e73 */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
--status-success: 23 107 46; /* #176b2e */
--status-danger: 179 0 14; /* #b3000e */
--status-warning: 122 79 0; /* #7a4f00 */
--syn-comment: 93 108 121; /* #5d6c79 */
--syn-string: 196 26 22; /* #c41a16 */
--syn-number: 28 0 207; /* #1c00cf */
--syn-keyword: 155 35 147; /* #9b2393 */
--syn-builtin: 11 79 121; /* #0b4f79 */
--syn-function: 50 109 116; /* #326d74 */
--syn-operator: 75 75 80; /* #4b4b50 */
--syn-punctuation: 75 75 80; /* #4b4b50 */
--syn-property: 57 0 160; /* #3900a0 */
--syn-tag: 155 35 147; /* #9b2393 */
--syn-add: 31 122 54; /* #1f7a36 */
--syn-del: 197 0 15; /* #c5000f */
}
[data-mode="light"][data-theme="intellij-light"] {
--surface-0: 236 238 242; /* #eceef2 */
--surface-1: 241 242 245; /* #f1f2f5 */
--surface-2: 247 248 250; /* #f7f8fa */
--surface-3: 253 253 254; /* #fdfdfe */
--surface-4: 255 255 255; /* #ffffff */
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 47 106 227; /* #2f6ae3 */
--accent-hover: 39 88 192; /* #2758c0 */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 108 112 126; /* #6c707e */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
}
[data-mode="light"][data-theme="canvas"] {
--surface-0: 227 220 205; /* #e3dccd */
--surface-1: 232 226 212; /* #e8e2d4 */
--surface-2: 237 232 220; /* #ede8dc */
--surface-3: 243 239 230; /* #f3efe6 */
--surface-4: 247 244 237; /* #f7f4ed */
--surface-5: 250 248 243; /* #faf8f3 */
--border: 203 191 166; /* #cbbfa6 */
--border-light: 220 211 192; /* #dcd3c0 */
--accent: 122 82 48; /* #7a5230 */
--accent-hover: 102 63 34; /* #663f22 */
--fg-primary: 43 38 32; /* #2b2620 */
--fg-secondary: 94 85 74; /* #5e554a */
--fg-muted: 107 97 85; /* #6b6155 */
--status-success: 54 93 36; /* #365d24 */
--status-danger: 147 41 31; /* #93291f */
--status-warning: 109 76 0; /* #6d4c00 */
--syn-comment: 125 114 98; /* #7d7262 */
--syn-string: 77 107 31; /* #4d6b1f */
--syn-number: 154 91 0; /* #9a5b00 */
--syn-keyword: 138 59 18; /* #8a3b12 */
--syn-builtin: 47 94 110; /* #2f5e6e */
--syn-function: 122 90 0; /* #7a5a00 */
--syn-operator: 138 48 80; /* #8a3050 */
--syn-punctuation: 107 97 85; /* #6b6155 */
--syn-property: 59 102 96; /* #3b6660 */
--syn-tag: 142 47 47; /* #8e2f2f */
--syn-add: 77 107 31; /* #4d6b1f */
--syn-del: 159 47 35; /* #9f2f23 */
}
@layer base {
* {
scrollbar-width: thin;
@@ -0,0 +1,71 @@
/**
* @file terminalTheme.test.ts
* @description Tests for the xterm theme builder: CSS-variable RGB triplets
* become hex, status/accent map to the ANSI hues, bright twins are lighter,
* and the probe resolves a named dark theme's variables.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { buildTerminalTheme, terminalThemeFor } from "../terminalTheme";
const vars: Record<string, string> = {
"--surface-0": " 17 17 19",
"--surface-3": "39 42 45",
"--fg-primary": "237 238 240",
"--fg-secondary": "176 180 186",
"--fg-muted": "105 110 119",
"--border-light": "67 72 78",
"--accent": "0 144 255",
"--status-success": "61 214 140",
"--status-danger": "255 149 146",
"--status-warning": "255 202 22",
};
describe("buildTerminalTheme", () => {
it("maps theme tokens onto the xterm palette", () => {
const t = buildTerminalTheme((n) => vars[n] ?? "");
expect(t.background).toBe("#111113");
expect(t.foreground).toBe("#edeef0");
expect(t.cursor).toBe("#edeef0");
expect(t.selectionBackground).toBe("#43484e");
expect(t.red).toBe("#ff9592");
expect(t.green).toBe("#3dd68c");
expect(t.yellow).toBe("#ffca16");
expect(t.blue).toBe("#0090ff");
expect(t.black).toBe("#43484e");
expect(t.brightBlack).toBe("#696e77");
expect(t.brightWhite).toBe("#edeef0");
});
it("makes each bright twin lighter than its base colour", () => {
const t = buildTerminalTheme((n) => vars[n] ?? "");
const sum = (hex: string) =>
[1, 3, 5].reduce((a, i) => a + parseInt(hex.slice(i, i + 2), 16), 0);
for (const c of ["red", "green", "yellow", "blue", "magenta", "cyan"] as const) {
const base = t[c]!;
const bright = t[`bright${c[0]!.toUpperCase()}${c.slice(1)}` as keyof typeof t] as string;
expect(sum(bright)).toBeGreaterThan(sum(base));
}
});
it("leaves a colour unset when its variable is missing, so xterm keeps its default", () => {
const t = buildTerminalTheme(() => "");
expect(t.background).toBeUndefined();
expect(t.red).toBeUndefined();
});
});
describe("terminalThemeFor", () => {
it("resolves variables on a dark probe scoped to the theme id, then removes it", () => {
const style = document.createElement("style");
style.textContent = `.dark { --surface-0: 1 2 3; } .dark[data-theme="darcula"] { --surface-0: 30 31 32; }`;
document.head.appendChild(style);
try {
expect(terminalThemeFor("default").background).toBe("#010203");
expect(terminalThemeFor("darcula").background).toBe("#1e1f20");
expect(document.body.children).toHaveLength(0);
} finally {
style.remove();
}
});
});
+46 -6
View File
@@ -482,6 +482,15 @@ export function dashboardToken(): string | null {
}
}
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
function ccQuery(scope?: CcScope, cwd?: string): string {
const qs = new URLSearchParams();
if (scope) qs.set("scope", scope);
if (cwd) qs.set("cwd", cwd);
const q = qs.toString();
return q ? `?${q}` : "";
}
/**
* Shared fetch wrapper used by every method on {@link api}. Prefixes `path`
* with {@link BASE} ("/api"), attaches the dashboard auth token (if any) as
@@ -957,7 +966,14 @@ export const api = {
node_version: string;
platform: string;
ws_connections: number;
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
memory: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
heapLimit?: number;
};
cpu_load: number[];
arch: string;
total_mem: number;
@@ -1383,10 +1399,11 @@ export const api = {
* list endpoints below (`agents`, `commands`, `outputStyles`).
*
* @param scope Optional {@link CcScope} ("user"|"project"|"all") filter.
* @param cwd Project directory to read, instead of the server's own cwd.
* @returns `{ items }` — the {@link CcMdItem} summaries for each skill.
*/
skills: (scope?: CcScope) =>
request<{ items: CcMdItem[] }>(`/cc-config/skills${scope ? `?scope=${scope}` : ""}`),
skills: (scope?: CcScope, cwd?: string) =>
request<{ items: CcMdItem[] }>(`/cc-config/skills${ccQuery(scope, cwd)}`),
/**
* GET /api/cc-config/agents - user and/or project subagent definitions.
* @param scope Optional {@link CcScope} filter.
@@ -1397,10 +1414,11 @@ export const api = {
/**
* GET /api/cc-config/commands - user and/or project slash commands.
* @param scope Optional {@link CcScope} filter.
* @param cwd Project directory to read, instead of the server's own cwd.
* @returns `{ items }` — {@link CcMdItem} summaries for each command.
*/
commands: (scope?: CcScope) =>
request<{ items: CcMdItem[] }>(`/cc-config/commands${scope ? `?scope=${scope}` : ""}`),
commands: (scope?: CcScope, cwd?: string) =>
request<{ items: CcMdItem[] }>(`/cc-config/commands${ccQuery(scope, cwd)}`),
/**
* GET /api/cc-config/output-styles.
* @param scope Optional {@link CcScope} filter.
@@ -2290,7 +2308,14 @@ export const api = {
* location isn't tied to a specific project (e.g. Sagi's mascot lane).
* @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created.
*/
ensure: (body: { cwd?: string; title?: string; pipeline?: string; tool?: LaneTool }) =>
ensure: (body: {
cwd?: string;
title?: string;
pipeline?: string;
tool?: LaneTool;
/** Only a lane on THIS directory counts, not one on a parent of it. */
exact?: boolean;
}) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
method: "POST",
body: JSON.stringify(body),
@@ -2437,6 +2462,16 @@ export const api = {
* @param id The lane id.
* @param path Repo-relative directory; "" for the repo root.
*/
/**
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
*/
gitTreeAll: (id: number) =>
request<{
available: boolean;
levels: Record<string, LaneTreeEntry[]>;
truncated: boolean;
}>(`/lanes/${id}/git/tree-all`),
gitTree: (id: number, path = "") =>
request<{
available: boolean;
@@ -2479,6 +2514,8 @@ export const api = {
| "stage"
| "unstage"
| "discard"
| "stage-hunk"
| "discard-hunk"
| "branch-create"
| "branch-delete",
body: {
@@ -2488,6 +2525,9 @@ export const api = {
base?: string;
create?: boolean;
paths?: string[];
path?: string;
hunk?: string;
context?: number;
confirm?: boolean;
} = {}
) =>
+3 -3
View File
@@ -1037,7 +1037,7 @@ export function highlight(source: string, lang: string): Token[] {
* (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
* pastel one this file has always used, and light mode takes the same hues
* four steps darker, because a pastel keyword on a light code block is
* unreadable. Diff rows keep their faint ±10% tint, which works on both.
* unreadable. Diff rows take a /20 status tint, pre-mixed per theme.
*/
export function tokenClass(type: TokenType): string {
switch (type) {
@@ -1068,9 +1068,9 @@ export function tokenClass(type: TokenType): string {
case "boolean":
return "text-syntax-number"; // literals grouped with numbers by colour
case "diff-add":
return "text-syntax-add bg-emerald-500/10"; // green text + faint green row tint
return "text-syntax-add bg-status-success/20"; // green text + theme-aware green row tint
case "diff-del":
return "text-syntax-del bg-red-500/10"; // red text + faint red row tint
return "text-syntax-del bg-status-danger/20"; // red text + theme-aware red row tint
case "diff-meta":
return "text-syntax-keyword";
case "plain":
+94
View File
@@ -0,0 +1,94 @@
/**
* @file terminalTheme.ts
* @description The xterm colour theme for lane terminals. The terminal is
* ALWAYS dark — Claude Code's TUI picks its own colours for a dark background,
* so a light terminal would make its text unreadable — and is painted with the
* palette of the DARK theme the user picked (`themeFor.dark`). Colours are read
* from the CSS variables in `index.css` on a hidden probe carrying
* `class="dark" data-theme=<id>`, so no hex is duplicated here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ITheme } from "@xterm/xterm";
/** Reads one CSS variable (an RGB triplet like `17 17 19`) by name. */
export type VarReader = (name: string) => string;
function toHex(triplet: string): string | undefined {
const parts = triplet
.trim()
.split(/[\s,]+/)
.map(Number);
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return undefined;
return "#" + parts.map((n) => Math.round(n).toString(16).padStart(2, "0")).join("");
}
/** Mix a hex colour toward white by `amount` (0..1) — the ANSI "bright" twin. */
function lighten(hex: string | undefined, amount: number): string | undefined {
if (!hex) return undefined;
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return (
"#" +
rgb
.map((c) =>
Math.round(c + (255 - c) * amount)
.toString(16)
.padStart(2, "0")
)
.join("")
);
}
/**
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
* theme (darcula's keyword is orange), and a program printing "magenta" should
* get magenta.
*/
export function buildTerminalTheme(read: VarReader): ITheme {
const v = (name: string) => toHex(read(name));
const red = v("--status-danger");
const green = v("--status-success");
const yellow = v("--status-warning");
const blue = v("--accent");
const magenta = "#c678dd";
const cyan = "#56b6c2";
return {
background: v("--surface-0"),
foreground: v("--fg-primary"),
cursor: v("--fg-primary"),
cursorAccent: v("--surface-0"),
selectionBackground: v("--border-light"),
black: v("--border-light"),
red,
green,
yellow,
blue,
magenta,
cyan,
white: v("--fg-secondary"),
brightBlack: v("--fg-muted"),
brightRed: lighten(red, 0.25),
brightGreen: lighten(green, 0.25),
brightYellow: lighten(yellow, 0.25),
brightBlue: lighten(blue, 0.25),
brightMagenta: lighten(magenta, 0.25),
brightCyan: lighten(cyan, 0.25),
brightWhite: v("--fg-primary"),
};
}
/** Resolve the dark theme `id`'s variables on a hidden probe and build the theme. */
export function terminalThemeFor(id: string): ITheme {
const probe = document.createElement("div");
probe.className = "dark";
if (id !== "default") probe.dataset.theme = id;
probe.style.display = "none";
document.body.appendChild(probe);
try {
const style = getComputedStyle(probe);
return buildTerminalTheme((name) => style.getPropertyValue(name));
} finally {
probe.remove();
}
}
+9 -9
View File
@@ -2805,7 +2805,7 @@ export const STATUS_CONFIG: Record<
// Green: the agent is actively running a tool / doing work.
working: {
labelKey: "common:status.working",
color: "text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
},
@@ -2813,21 +2813,21 @@ export const STATUS_CONFIG: Record<
// on user input - attention may be required.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
},
// Violet: the agent finished cleanly.
completed: {
labelKey: "common:status.completed",
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
},
// Red: the agent ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-400",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
},
@@ -3013,28 +3013,28 @@ export const SESSION_STATUS_CONFIG: Record<
// Green: the session is live and running.
active: {
labelKey: "common:status.active",
color: "text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
},
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
},
// Violet: the session ended cleanly (SessionEnd).
completed: {
labelKey: "common:status.completed",
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
},
// Red: the session ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-400",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
},
@@ -3044,7 +3044,7 @@ export const SESSION_STATUS_CONFIG: Record<
// Muted slate distinguishes "given up / faded out" from yellow Waiting
// (attention required).
labelKey: "common:status.abandoned",
color: "text-slate-400",
color: "text-slate-600 dark:text-slate-400",
bg: "bg-slate-500/10 border-slate-500/20",
dot: "bg-slate-400",
},
+3 -1
View File
@@ -341,7 +341,9 @@ export function ActivityFeed() {
<p className="text-xs text-fg-muted">
{t("subtitle")}
{paused && (
<span className="ml-2 text-yellow-400">{t("paused", { count: bufferCount })}</span>
<span className="ml-2 text-yellow-700 dark:text-yellow-400">
{t("paused", { count: bufferCount })}
</span>
)}
</p>
</div>
+14 -14
View File
@@ -970,7 +970,7 @@ type Tone =
const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring: string }> = {
sky: {
iconBg: "bg-sky-500/10",
iconText: "text-sky-300",
iconText: "text-sky-700 dark:text-sky-300",
bar: "bg-sky-500/40",
ring: "ring-sky-500/20",
},
@@ -982,7 +982,7 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
},
violet: {
iconBg: "bg-violet-500/10",
iconText: "text-violet-300",
iconText: "text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/40",
ring: "ring-violet-500/20",
},
@@ -994,37 +994,37 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
},
fuchsia: {
iconBg: "bg-fuchsia-500/10",
iconText: "text-fuchsia-300",
iconText: "text-fuchsia-700 dark:text-fuchsia-300",
bar: "bg-fuchsia-500/40",
ring: "ring-fuchsia-500/20",
},
cyan: {
iconBg: "bg-cyan-500/10",
iconText: "text-cyan-300",
iconText: "text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/40",
ring: "ring-cyan-500/20",
},
pink: {
iconBg: "bg-pink-500/10",
iconText: "text-pink-300",
iconText: "text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/40",
ring: "ring-pink-500/20",
},
indigo: {
iconBg: "bg-indigo-500/10",
iconText: "text-indigo-300",
iconText: "text-indigo-700 dark:text-indigo-300",
bar: "bg-indigo-500/40",
ring: "ring-indigo-500/20",
},
orange: {
iconBg: "bg-orange-500/10",
iconText: "text-orange-300",
iconText: "text-orange-700 dark:text-orange-300",
bar: "bg-orange-500/40",
ring: "ring-orange-500/20",
},
teal: {
iconBg: "bg-teal-500/10",
iconText: "text-teal-300",
iconText: "text-teal-700 dark:text-teal-300",
bar: "bg-teal-500/40",
ring: "ring-teal-500/20",
},
@@ -1036,7 +1036,7 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
},
rose: {
iconBg: "bg-rose-500/10",
iconText: "text-rose-300",
iconText: "text-rose-700 dark:text-rose-300",
bar: "bg-rose-500/40",
ring: "ring-rose-500/20",
},
@@ -2721,7 +2721,7 @@ function CurrentConfigPanel({ sources }: { sources: CcSettingsSource[] }) {
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<SettingsIcon className="w-3.5 h-3.5 text-violet-300/80" />
<SettingsIcon className="w-3.5 h-3.5 text-violet-700/80 dark:text-violet-300/80" />
<span className="text-sm font-medium text-fg-primary">Current configuration</span>
<span className="text-[11px] text-fg-muted ml-auto">
{setCount} option{setCount !== 1 ? "s" : ""} set · the rest use defaults
@@ -3233,7 +3233,7 @@ function renderMemoryIndex(
type="button"
onClick={() => onJump(capturedTarget)}
title={`${jumpTitle}: ${normalizeMemoryTarget(capturedTarget)}`}
className="text-teal-300 hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
className="text-teal-700 dark:text-teal-300 hover:text-teal-800 dark:hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
>
{label}
</button>
@@ -3386,7 +3386,7 @@ function MemoryPanel({
{totalAuto > 0 && (
<section className="pt-1">
<div className="flex items-center gap-2 mb-1">
<BookOpen className="w-3.5 h-3.5 text-teal-300" />
<BookOpen className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300" />
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-secondary">
{t("memory.autoTitle")}
</h3>
@@ -3499,7 +3499,7 @@ function MemoryProjectGroup({
open ? "" : "-rotate-90"
}`}
/>
<FolderTree className="w-3.5 h-3.5 text-teal-300 flex-shrink-0" />
<FolderTree className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-xs text-fg-secondary truncate flex-1 min-w-0">
{project}
</span>
@@ -3618,7 +3618,7 @@ function MemoryIndexCard({
return (
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
<BookOpen className="w-3 h-3 text-teal-300 flex-shrink-0" />
<BookOpen className="w-3 h-3 text-teal-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
{item.name}
</span>
+67 -48
View File
@@ -132,7 +132,14 @@ interface SystemInfo {
node_version: string;
platform: string;
ws_connections: number;
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
memory: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
heapLimit?: number;
};
cpu_load: number[];
arch: string;
total_mem: number;
@@ -205,27 +212,16 @@ function useTooltip() {
return { show, move, hide, node };
}
function cellColor(count: number, max: number) {
if (count === 0) return "#161625";
// Log scale + RGB interpolation across a wide color ramp for maximum perceptual range
/** Heatmap cell fill: the page surface mixed toward the accent on a log
* scale. Built from theme tokens so the ramp reads surface → accent in every
* theme; the old fixed indigo ramp went near-black on light themes. Exported
* for its test. */
export function cellColor(count: number, max: number) {
if (count === 0) return "rgb(var(--surface-0))";
const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
// Ramp: near-black indigo → deep indigo → bright indigo → lavender
type RGB = [number, number, number];
const stops: RGB[] = [
[22, 20, 60], // near-black indigo
[55, 48, 163], // deep indigo
[99, 102, 241], // bright indigo
[199, 210, 254], // lavender
];
const scaled = t * (stops.length - 1);
const lo = Math.min(Math.floor(scaled), stops.length - 2);
const frac = scaled - lo;
const [r1, g1, b1]: RGB = stops[lo] as RGB;
const [r2, g2, b2]: RGB = stops[lo + 1] as RGB;
const r = Math.round(r1 + (r2 - r1) * frac);
const g = Math.round(g1 + (g2 - g1) * frac);
const b = Math.round(b1 + (b2 - b1) * frac);
return `rgb(${r},${g},${b})`;
// Floor at 20% so a single event still reads as distinct from an empty day.
const pct = Math.round(20 + 80 * t);
return `color-mix(in srgb, rgb(var(--accent)) ${pct}%, rgb(var(--surface-0)))`;
}
function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }>> }) {
@@ -345,7 +341,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
height: 13,
borderRadius: 2,
backgroundColor: cellColor(cell.count, maxCount),
border: "1px solid rgba(255,255,255,0.04)",
border: "1px solid rgb(var(--border) / 0.4)",
flexShrink: 0,
cursor: "default",
}}
@@ -367,7 +363,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
height: 13,
borderRadius: 2,
backgroundColor: cellColor(v, maxCount),
border: "1px solid rgba(255,255,255,0.06)",
border: "1px solid rgb(var(--border) / 0.5)",
}}
/>
);
@@ -586,7 +582,14 @@ function DonutChart({
<div className="flex items-center justify-center gap-6 w-full">
{node}
<svg width={128} height={128} viewBox="0 0 128 128" className="flex-shrink-0">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#1e1e2e" strokeWidth={stroke} />
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke="rgb(var(--border))"
strokeWidth={stroke}
/>
{segments.map(({ label, value, color }, i) => {
const dash = (value / total) * circumference;
const gap = circumference - dash;
@@ -1003,7 +1006,7 @@ function AnalyticsTab() {
raw={data ? totalTokens.toLocaleString() : undefined}
sub={data ? `${cacheHitPct}${t("cacheHitRate")}` : undefined}
icon={Cpu}
color="text-violet-400"
color="text-violet-700 dark:text-violet-400"
loading={!data}
/>
<StatPill
@@ -1025,7 +1028,7 @@ function AnalyticsTab() {
raw={data ? data.overview.total_events.toLocaleString() : undefined}
sub={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
icon={Zap}
color="text-yellow-400"
color="text-yellow-700 dark:text-yellow-400"
loading={!data}
/>
</div>
@@ -1148,7 +1151,9 @@ function AnalyticsTab() {
</div>
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("cacheEfficiency")}</span>
<span className="text-violet-400 font-mono">{cacheHitPct}%</span>
<span className="text-violet-700 dark:text-violet-400 font-mono">
{cacheHitPct}%
</span>
</div>
</div>
</div>
@@ -1173,12 +1178,12 @@ function AnalyticsTab() {
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
},
{ label: t("common:total"), value: totalTokens, color: "text-fg-primary" },
].map(({ label, value, color }) => (
@@ -1324,7 +1329,7 @@ function AnalyticsTab() {
</div>
<div className="mt-4 pt-4 border-t border-border text-xs flex justify-between">
<span className="text-fg-muted">{t("common:total")}</span>
<span className="text-cyan-400 font-mono">
<span className="text-cyan-700 dark:text-cyan-400 font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
@@ -1562,10 +1567,10 @@ function SystemHealthTab() {
const memUsedPct =
info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0;
const heapUsedPct =
info.server.memory.heapTotal > 0
? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100
: 0;
// Against the heap LIMIT: heapTotal is only what V8 has grown to so far, so
// used/total reads 70-90% on a healthy process and dragged the health score.
const heapMax = info.server.memory.heapLimit || info.server.memory.heapTotal;
const heapUsedPct = heapMax > 0 ? (info.server.memory.heapUsed / heapMax) * 100 : 0;
return {
totalEntries,
@@ -1701,7 +1706,7 @@ function SystemHealthTab() {
</Tip>
<Tip
block
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapLimit || info.server.memory.heapTotal)} limit\nAllocated: ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
@@ -1754,7 +1759,14 @@ function SystemHealthTab() {
viewBox="0 0 96 96"
className="flex-shrink-0 cursor-default"
>
<circle cx="48" cy="48" r="38" fill="none" stroke="#1e1e2e" strokeWidth="14" />
<circle
cx="48"
cy="48"
r="38"
fill="none"
stroke="rgb(var(--border))"
strokeWidth="14"
/>
{(() => {
const r = 38,
cx = 48,
@@ -1875,7 +1887,14 @@ function SystemHealthTab() {
raw={`Score: ${healthScore.toFixed(1)} / 100\n\n• Success Rate (40%): ${successRate.toFixed(1)}%\n• Cache Hit (25%): ${cacheHitRate.toFixed(1)}%\n• Error Avoidance (25%): ${(100 - errorRate).toFixed(1)}%\n• Memory Health (10%): ${(100 - heapUsedPct).toFixed(1)}%`}
>
<svg width="120" height="120" viewBox="0 0 120 120" className="cursor-default">
<circle cx="60" cy="60" r="48" fill="none" stroke="#1e1e2e" strokeWidth="10" />
<circle
cx="60"
cy="60"
r="48"
fill="none"
stroke="rgb(var(--border))"
strokeWidth="10"
/>
<circle
cx="60"
cy="60"
@@ -1947,7 +1966,7 @@ function SystemHealthTab() {
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Compact</p>
<p className="text-xs font-mono font-bold text-violet-400">
<p className="text-xs font-mono font-bold text-violet-700 dark:text-violet-400">
{workflow.compaction?.totalCompactions ?? 0}
</p>
</div>
@@ -2029,7 +2048,7 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BarChart3 className="w-4 h-4 text-violet-400" />
<BarChart3 className="w-4 h-4 text-violet-700 dark:text-violet-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Concurrency</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">{lanes.length} intervals</span>
@@ -2051,7 +2070,7 @@ function SystemHealthTab() {
? "#fbbf24"
: lane.count > 0
? "#34d399"
: "#1e1e2e";
: "rgb(var(--border))";
return (
<div
key={i}
@@ -2165,7 +2184,7 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<GitBranch className="w-4 h-4 text-violet-400" />
<GitBranch className="w-4 h-4 text-violet-700 dark:text-violet-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
Subagent Effectiveness
</span>
@@ -2275,7 +2294,7 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-cyan-400" />
<Cpu className="w-4 h-4 text-cyan-700 dark:text-cyan-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Platform</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">{info.server.node_version}</span>
@@ -2613,7 +2632,7 @@ export function Dashboard() {
label={t("activeSubagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-violet-400"
accentColor="text-violet-700 dark:text-violet-400"
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
loading={!stats}
/>
@@ -2622,7 +2641,7 @@ export function Dashboard() {
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
icon={Zap}
accentColor="text-yellow-400"
accentColor="text-yellow-700 dark:text-yellow-400"
loading={!stats}
/>
<StatCard
@@ -2630,7 +2649,7 @@ export function Dashboard() {
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
icon={Activity}
accentColor="text-violet-400"
accentColor="text-violet-700 dark:text-violet-400"
loading={!stats}
/>
<StatCard
@@ -2714,7 +2733,7 @@ export function Dashboard() {
empty rather than as a misalignment. */}
{!hasChildren && !isSubagent && (
<span
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-violet-400/70"
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-violet-700/70 dark:text-violet-400/70"
aria-hidden="true"
title={t("common:noSubagents", "No subagents")}
>
@@ -2722,7 +2741,7 @@ export function Dashboard() {
</span>
)}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-400 flex-shrink-0" />
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
@@ -2751,7 +2770,7 @@ export function Dashboard() {
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-400 hover:text-violet-300 transition-colors"
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
{activeDesc > 0 && (
+18 -12
View File
@@ -664,23 +664,29 @@ export function SessionDetail() {
}`}
>
<ReasonIcon
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-300"}`}
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-700 dark:text-yellow-300"}`}
/>
</span>
<div className="flex-1 min-w-0">
<div
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-200"}`}
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-800 dark:text-yellow-200"}`}
>
{t("detail.waitingBanner.title")}
{cfg && (
<span className={urgent ? "text-status-warning/90" : "text-yellow-300/80"}>
<span
className={
urgent
? "text-status-warning/90"
: "text-yellow-700/80 dark:text-yellow-300/80"
}
>
{" · "}
{t(cfg.labelKey)}
</span>
)}
</div>
<div
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-400/60"}`}
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-700/60 dark:text-yellow-400/60"}`}
>
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
</div>
@@ -688,7 +694,7 @@ export function SessionDetail() {
{session.awaiting_input_since && (
<span
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
urgent ? "text-status-warning/80" : "text-yellow-400/70"
urgent ? "text-status-warning/80" : "text-yellow-700/70 dark:text-yellow-400/70"
}`}
>
<span
@@ -736,7 +742,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "agents"
? "border-violet-500 text-violet-400"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -750,7 +756,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "conversation"
? "border-violet-500 text-violet-400"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -764,7 +770,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "timeline"
? "border-violet-500 text-violet-400"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -794,7 +800,7 @@ export function SessionDetail() {
{workflows.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Workflow className="w-3.5 h-3.5 text-violet-400" />
<Workflow className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
{wfT("runs.sessionTitle")}
<span className="text-fg-muted font-mono">· {workflows.length}</span>
</h3>
@@ -807,7 +813,7 @@ export function SessionDetail() {
) : (
<>
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5 text-violet-400" />
<Bot className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
{t("detail.agents")}
<span className="text-fg-muted font-mono">· {agents.length}</span>
</h3>
@@ -885,7 +891,7 @@ export function SessionDetail() {
)}
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-400 flex-shrink-0" />
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
@@ -928,7 +934,7 @@ export function SessionDetail() {
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-400 hover:text-violet-300 transition-colors"
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
</button>
+1 -1
View File
@@ -436,7 +436,7 @@ export function Sessions() {
</p>
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
+23 -14
View File
@@ -59,6 +59,7 @@ import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragmen
import { useTranslation } from "react-i18next";
import {
DollarSign,
Palette,
Plus,
Pencil,
Trash2,
@@ -118,6 +119,7 @@ import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import type { ModelPricing, WSMessage } from "../lib/types";
// In-page navigation for the (dense) Settings screen. Each entry maps to a
@@ -128,6 +130,7 @@ const SETTINGS_SECTIONS: {
fallback?: string;
Icon: typeof DollarSign;
}[] = [
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
@@ -877,7 +880,9 @@ export function Settings() {
// just Sonnet 5), so a future model with a launch promo needs no code change.
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
<label className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-violet-300/70">{t(labelKey)}</span>
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
{t(labelKey)}
</span>
<input
type={opts.date ? "text" : "number"}
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
@@ -893,7 +898,7 @@ export function Settings() {
<td colSpan={10} className="px-4 pb-3 pt-2">
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[11px] font-semibold text-violet-300 uppercase tracking-wider">
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
{t("pricing.introRatesTitle")}
</span>
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
@@ -1080,6 +1085,8 @@ export function Settings() {
</div>
</div>
<AppearanceSettings />
{/* ─── MODEL PRICING ─── */}
<section id="pricing" className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
@@ -1182,7 +1189,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-400/80 mt-0.5">
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
input: rule.intro_input_per_mtok,
output: rule.intro_output_per_mtok,
@@ -1194,7 +1201,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_input_per_mtok}
</span>
)}
@@ -1202,7 +1209,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_output_per_mtok}
</span>
)}
@@ -1210,7 +1217,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_read_per_mtok}
</span>
)}
@@ -1218,7 +1225,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_per_mtok}
</span>
)}
@@ -1226,7 +1233,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_1h_per_mtok}
</span>
)}
@@ -1575,7 +1582,7 @@ export function Settings() {
/>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<CheckCircle className="w-4 h-4 text-violet-400 flex-shrink-0" />
<CheckCircle className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
<Toggle
checked={notifPrefs.onSessionComplete}
onChange={(v) => updateNotifPrefs({ onSessionComplete: v })}
@@ -1683,9 +1690,11 @@ export function Settings() {
const tableIcons: Record<string, React.ReactNode> = {
sessions: <Layers className="w-4 h-4 text-blue-500" />,
agents: <Users className="w-4 h-4 text-status-success" />,
events: <Activity className="w-4 h-4 text-violet-400" />,
events: <Activity className="w-4 h-4 text-violet-700 dark:text-violet-400" />,
token_usage: <Coins className="w-4 h-4 text-status-warning" />,
model_pricing: <BarChart3 className="w-4 h-4 text-cyan-400" />,
model_pricing: (
<BarChart3 className="w-4 h-4 text-cyan-700 dark:text-cyan-400" />
),
};
const tableLabels: Record<string, string> = {
sessions: t("tables.sessions"),
@@ -1720,7 +1729,7 @@ export function Settings() {
})()}
<div className="bg-surface-2 rounded-lg px-3 py-3 border-l-2 border-indigo-500/20">
<div className="flex items-center gap-2 mb-1.5">
<HardDrive className="w-4 h-4 text-indigo-400" />
<HardDrive className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("data.dbSize")}
</p>
@@ -1865,7 +1874,7 @@ export function Settings() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Server className="w-4 h-4 text-indigo-400" />
<Server className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.release")}
</p>
@@ -1903,7 +1912,7 @@ export function Settings() {
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Globe className="w-4 h-4 text-violet-400" />
<Globe className="w-4 h-4 text-violet-700 dark:text-violet-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.platform")}
</p>
+1 -1
View File
@@ -224,7 +224,7 @@ export function Workflows() {
<div className="card p-4 space-y-3">
<div>
<h2 className="text-sm font-semibold text-fg-secondary flex items-center gap-2">
<Workflow className="w-4 h-4 text-violet-400" />
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400" />
{t("runs.title")}
</h2>
<p className="text-xs text-fg-muted mt-0.5">{t("runs.subtitle")}</p>
+26 -10
View File
@@ -53,6 +53,7 @@ import {
PanelLeftClose,
ChevronsLeftRight,
PanelRightClose,
Maximize2,
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
@@ -88,21 +89,25 @@ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
* narrow → 50/50 → 70/30 → closed, rather than a width button beside a
* narrow → 50/50 → 70/30 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "wide"] as const;
const CHANGES_SIZES = ["compact", "half", "wide", "full"] as const;
type ChangesSize = (typeof CHANGES_SIZES)[number];
const CHANGES_WIDTH: Record<ChangesSize, string> = {
compact: "w-[28rem] xl:w-[34rem]",
half: "w-1/2",
wide: "w-[70%]",
// The console beside it is HIDDEN, not unmounted: a live run's terminal
// and a half-typed prompt must survive a round trip through full width.
full: "w-full",
};
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, a wider panel, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
compact: Columns2,
half: ChevronsLeftRight,
wide: PanelRightClose,
wide: Maximize2,
full: PanelRightClose,
};
// ── Page ──────────────────────────────────────────────────────────────
@@ -733,7 +738,9 @@ export function Workspace() {
<div className="flex min-h-0 flex-1 gap-4">
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}${
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
}`}
>
{/* One header row, not two. The pipeline picker, the stage badges,
the feature picker and the router select used to sit on a second
@@ -874,7 +881,8 @@ export function Workspace() {
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3">
{/* No margin when the card rendered nothing (see LaneCard). */}
<div className="mb-3 has-[>div:empty]:mb-0">
<LaneCard
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
@@ -882,10 +890,6 @@ export function Workspace() {
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
)}
onSelectLane={setSelectedLaneId}
onNewLaneFromRepo={(repo, branch) => {
setAddLanePrefill({ sourceRepo: repo, branch });
setAddLaneOpen(true);
}}
/>
</div>
<div className="mb-3">
@@ -1014,7 +1018,19 @@ export function Workspace() {
})()}
</button>
</div>
<LaneChangesPanel laneId={currentLane.id} />
<LaneChangesPanel
laneId={currentLane.id}
// 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={(branch) => {
setAddLanePrefill({
sourceRepo: currentLane.source_repo || currentLane.cwd,
branch,
});
setAddLaneOpen(true);
}}
/>
</aside>
) : (
<button
@@ -0,0 +1,30 @@
/**
* @file Dashboard.heatmap.test.ts
* @description Pins the activity heatmap's colour ramp to the theme tokens: it
* runs from the page surface to the accent in every theme, instead of a fixed
* near-black indigo that vanished into dark and blotted out light themes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { cellColor } from "../Dashboard";
describe("heatmap cellColor", () => {
it("paints an empty day as the bare surface", () => {
expect(cellColor(0, 10)).toBe("rgb(var(--surface-0))");
});
it("mixes the accent into the surface, more for busier days, and never a fixed rgb", () => {
const low = cellColor(1, 100);
const high = cellColor(100, 100);
for (const c of [low, high]) {
expect(c).toMatch(
/^color-mix\(in srgb, rgb\(var\(--accent\)\) \d+%, rgb\(var\(--surface-0\)\)\)$/
);
}
const pct = (c: string) => Number(/(\d+)%/.exec(c)?.[1]);
expect(pct(high)).toBe(100);
expect(pct(low)).toBeGreaterThan(0);
expect(pct(low)).toBeLessThan(pct(high));
});
});
@@ -142,7 +142,7 @@ describe("Workspace — the Changes panel's width", () => {
}
}
it("steps narrow → 50/50 → 70/30 → closed on one control", async () => {
it("steps narrow → 50/50 → 70/30 → full → closed on one control", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[28rem]");
@@ -153,6 +153,11 @@ describe("Workspace — the Changes panel's width", () => {
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-[70%]");
// Full width HIDES the console beside it — hidden, still mounted.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-full");
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
// Past the widest step the panel closes — and reopens narrow, not back at
// 70/30, which is why closing resets the width.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
File diff suppressed because it is too large Load Diff
+67 -16
View File
@@ -145,7 +145,10 @@ path. Existing cwd values still pass through unchanged. `pipeline` is
optional and, like `title`, applies **only when a lane is actually created**
— an existing lane is returned untouched. Omitted, the lane gets `default`.
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
field. An id no template defines returns `400 EBADPIPELINE`.
field. `exact: true` makes only a lane on this very directory count: without
it a `cwd` nested inside another lane returns that parent lane (`created:
false`); with it the nested directory gets a lane of its own. The Add-lane
modal sends it, and says so when the directory already has a lane. An id no template defines returns `400 EBADPIPELINE`.
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
@@ -389,6 +392,9 @@ What the lane's directory looks like to git right now:
}
```
- `dirty`, `untracked` and `files` cover only the lane's own directory, with
cwd-relative, unquoted paths — a lane that is a subdirectory of its repo does
not count its siblings' files (the same scoping as `/git/changes`).
- A `cwd` that is missing, is not a git repository, or makes git fail returns
`{"available": false}` with HTTP **200** — a normal state, not a fault.
- `files` names the files the counts came from, **capped at 20** (the counts
@@ -429,6 +435,12 @@ unresolvable one is `400 EBADREV`, never an empty diff).
range the diff has effectively become the whole file, which is what "expand
all" means in the panel.
Every path on the lane git surface (reads and writes) is **relative to the
lane's `cwd`**, even when that cwd is a subdirectory of its repo: changes
outside the cwd are not listed and cannot be named. Paths are literal file
names, never pathspec globs — `new*.js` or `app/[id]/page.js` names exactly
that file.
```json
{
"available": true,
@@ -486,6 +498,7 @@ GET /api/lanes/:id/git/log?limit=30[&file=<path>]
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
GET /api/lanes/:id/git/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir>
GET /api/lanes/:id/git/tree-all
```
The reads behind the Changes panel's **History**, whole-file view, quick-open
@@ -494,19 +507,33 @@ and file explorer. All four are read-only and unguarded, and all four answer
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
commits touching that path. Reading one commit's diff is
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
`rev=work` (default) is the file on disk — the one branch that reads the
filesystem, and it is realpath-confined to the lane directory; anything else
goes through `git show <rev>:<path>`. Binary content is reported, never
returned. Capped at 1 MB. `400 EBADPATH`, `EBADREV` or `ENOFILE`.
filesystem, and it is realpath-confined to the lane directory (a symlink
resolving outside it is `EBADPATH`); anything else goes through
`git show <rev>:./<path>` (cwd-relative). At EVERY rev the path must be
relative with no `..` segment (else `400 EBADPATH`), and it is normalised
(`./a//b` → `a/b`, also the `path` echoed back). Binary content is reported,
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
(where a lane's AI Router key lives) is `403 ESECRET` at EVERY rev and at
ANY depth (`sub/.claude/settings.local.json`, a subdirectory lane's key, as
seen from a lane at the repo root), and so is any path that RESOLVES to it
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
fact ANY file named `settings.local.json`, in any directory and at any rev,
is refused — a symlink target's name is all a lane above it, or a commit of
it, ever sees. Its hunks are never returned by `/git/changes` either.
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
reads `git ls-files`, so ignored files never appear and no skip-list has to
be maintained; basename hits rank first.
- **`/git/tree`** → `{available, path, entries:[{name, path, dir}], truncated}`
— one directory level, tracked entries only, directories folded and listed
first. `400 EBADPATH` for a path that escapes the lane.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the tracked tree from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
#### Read a lane's own branches
@@ -527,16 +554,20 @@ POST /api/lanes/:id/git/:action
```
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
per lane under the lane lock.
per git index (the lane lock keyed on the worktree's git dir), so a claude and
a codex lane on one cwd — or a root and a subdirectory lane of one repo —
cannot collide on `index.lock`.
| Action | Runs | Body |
| --- | --- | --- |
| `fetch` | `git fetch --all --prune` | — |
| `pull` | `git pull --ff-only` | — |
| `push` | `git push -u origin HEAD` | — |
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk, off the working tree | `{"path": "...", "hunk": "@@ ... @@", "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
@@ -555,18 +586,36 @@ per lane under the lane lock.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
`discard` accept only paths `git status` is reporting as changed right now,
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
- `discard` is the one irreversible action here and it **requires**
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice.
A `commit` without `only: "staged"` sweeps in (`git add -A -- .`) only the
lane cwd's own changes, and is `400 ENOCHANGES` when the lane has none —
even if the repo is dirty elsewhere.
- `discard` and `discard-hunk` are the irreversible actions here and both
**require** `confirm: true` (`400 ECONFIRM` without it). The UI additionally
asks twice.
- A hunk is named by its `@@` line and matched against a FRESH diff at the
`context` the caller read it with (send the `context` the changes read
returned), so a hunk the file has moved past is `409 EHUNKGONE`, never a
stale patch applied somewhere else. The path goes through the same
`git status` check; an untracked file has no hunks (`400 EUNTRACKED`). A
hunk that no longer applies (e.g. part of it is already staged) is
`409 EGIT`.
- `branch-create` never switches — the usual reason to create one from a lane
is to spawn a sibling worktree lane, and moving this pane's HEAD under a
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`); a git failure is
**500** with git's own `stderr` appended to `error.message` — a rejection or a
credential failure reads better in git's words.
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
non-zero (a rejected push, a credential failure, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
rejection reads better in git's words. Anything else (git not installed, a
timeout kill) is **500**, still under a string code (`EGIT` when there is
none) — `error.code` is never git's numeric exit status.
- In a lane whose cwd is a subdirectory of its repo, `commit` refuses with
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
`git commit` takes the whole index whatever the cwd, and scoping it with a
pathspec would commit the working-tree copy (unstaged edits included).
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
remote fails instead of holding the request open.
@@ -1801,9 +1850,9 @@ The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Cl
```http
GET /api/cc-config/overview
GET /api/cc-config/skills?scope=user|project|all
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
GET /api/cc-config/agents
GET /api/cc-config/commands
GET /api/cc-config/commands?scope=user|project|all&cwd=<dir>
GET /api/cc-config/output-styles
GET /api/cc-config/plugins
GET /api/cc-config/marketplaces
@@ -1839,6 +1888,8 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
POST /api/cc-config/mcp/remove Body: { name, scope? }
```
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a lane's own project commands and skills are the ones offered.
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
+27 -19
View File
@@ -472,7 +472,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
```
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
- A relative or missing `cwd` returns `400 EBADCWD`.
@@ -480,8 +480,8 @@ Two concurrent calls for the same `(cwd, tool)` pair always yield one lane: the
### Reading a lane's working copy: `GET /api/lanes/:id/git`
Each lane card shows the branch its working copy is actually on, the short
HEAD, that commit's subject, and how much is uncommitted:
The top of a lane's **Changes** panel shows the branch its working copy is
actually on, the short HEAD, that commit's subject, and how much is uncommitted:
```bash
curl http://localhost:4820/api/lanes/5/git
@@ -505,7 +505,7 @@ curl http://localhost:4820/api/lanes/5/git
- A `cwd` that is missing, is not a git repository, or makes git fail returns
`{"available": false}` with HTTP **200**. A lane pointing at a plain directory
is a normal state, not a fault, and the card simply renders without a git row.
is a normal state, not a fault, and the panel simply renders without a git line.
- A detached checkout reports the literal `HEAD` that git returns, rather than a
prettier invention.
- `files` names the files the two counts came from, **capped at 20** — a lane
@@ -520,8 +520,8 @@ curl http://localhost:4820/api/lanes/5/git
read off disk, since a lane mid-build can have a 100 MB artifact), a **binary**
file, a **rename** (numstat keys those differently), or a repo with no commit
to diff against yet.
- Hovering (or focusing, or tapping) the `N modified · N untracked` text on a
lane card opens this list as a panel: one row per file, its status letter
- Hovering (or focusing, or tapping) the `N modified · N untracked` text at the
top of the Changes panel opens this list as a panel: one row per file, its status letter
colour-coded, the directory truncated before the filename ever is, and each
row's `+N −M` aligned on the right under a total.
- An unknown lane id is a `404`.
@@ -529,32 +529,31 @@ curl http://localhost:4820/api/lanes/5/git
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
five times, and the lane list is polled and re-broadcast on every hook-driven
`lane_update`. Putting it there would fire a burst of subprocesses behind every
tool call an agent makes. The browser fetches it per card instead, on mount and
every 30 seconds, and a failed fetch is silent.
tool call an agent makes. The Changes panel fetches it instead, on mount, every 30 seconds and after
every git write it or its action row makes, and a failed fetch is silent.
The same response also carries the branch's standing against its remote:
`hasRemote`, `upstream`, `ahead`, `behind`. A branch that has never been pushed
reports `upstream: null` and **null** counts — not zeros, which would claim "in
sync" about a comparison that was never possible. The card renders them as
sync" about a comparison that was never possible. The panel renders them as
`↑2 ↓1` beside the git actions below.
### Acting on a lane's working copy: the git action row
Under the git line, a lane's card carries the git **writes** the dashboard
offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`):
On the same row as the git line — as icons, each naming itself in its tooltip —
the Changes panel carries the per-repo git **writes** the dashboard offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`):
| Button | Runs | Refuses when |
| --- | --- | --- |
| **Fetch** | `git fetch --all --prune` | the repo has no remote |
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
| **Push** | `git push -u origin HEAD` | the remote rejects it |
| **Commit** | `git add -A` + `git commit -m …`, or **Commit staged** (the index as it stands) | nothing is uncommitted / nothing is staged |
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
| **Create** | `git branch <name>` — never switches | the branch already exists |
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
These sit on the card rather than in the ⋯ menu deliberately: that menu holds
These sit in the panel rather than in the lane card's ⋯ menu deliberately: that menu holds
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
lane needs several times an hour. The boring half of git is the whole list on
purpose — there is no merge, rebase, reset or stash button, because a dashboard
@@ -565,7 +564,9 @@ deliberately does not switch: the usual reason to make a branch from a lane is
to spawn a sibling worktree lane on it, and moving this pane's HEAD under a
working agent is not. **New lane here** goes through the normal Add-lane flow
with the fields prefilled — one provisioning path for every lane, not a second
one hidden in the git bar.
one hidden in the git bar. There is no **Commit** here: committing is the
overview's staged-only box below, the one commit path, and it only ever takes
what was staged.
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
says more than any sentence the dashboard could write.
@@ -574,7 +575,7 @@ says more than any sentence the dashboard could write.
To the right of the lane detail sits a collapsible **Changes** card (its state
persists per viewer in `localStorage`). Its header holds **one** control, which
steps narrow → 50/50 → 70/30 → closed (`agenthub.workspace.changesSize`
steps narrow → 50/50 → 70/30 → full width (the console beside it is hidden, never unmounted) → closed (`agenthub.workspace.changesSize`
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
narrow. Reading a diff and watching a console want opposite amounts of room and
which one is "work" changes several times an hour, so the width is a step, not
@@ -600,6 +601,11 @@ only one that answers "show me the code" rather than "show me the change"):
target-practice exercise against one chevron.) One toolbar button folds or
unfolds all of them. What is remembered is the set of *collapsed* paths, so
a file that appears later is open by default; a lane switch clears it.
Each file's header stays **pinned** at the top while its diff scrolls past,
and carries the same stage / unstage / discard buttons as its overview row.
On a file with unstaged changes every `@@` row adds **Stage** and **Undo**
for that hunk alone (`stage-hunk` / `discard-hunk`); Undo asks twice, like
discard. The single-file view offers the same hunk buttons.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
@@ -637,10 +643,12 @@ in every panel narrow enough to sit beside a console.
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
one file means nothing unless a commit can take the index as it stands, so the
overview grows a commit box the moment anything is staged — it sends
overview grows a commit box the moment anything is staged — above the list and
pinned while it scrolls, beside a **Stage all** that stages every listed file
the index lacks in one call — it sends
`only: "staged"`, never `add -A`, and it is absent with a clean index because
there is nothing it could mean. (The git bar's own **Commit staged** is the
same call from the lane card; its plain **Commit** is still stage-everything.) **Discard** is the one
there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
irreversible action in the panel: it asks twice in the UI, and the server
refuses it without `confirm: true` and accepts only paths `git status` is
already reporting as changed.
@@ -1483,7 +1491,7 @@ curl -X POST http://localhost:4820/api/lanes/5/stop
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
The git writes the lane card's action row and the Changes panel offer. See
The git writes the Changes panel and its action row offer. See
"Acting on a lane's working copy" above for what each one refuses and why.
```bash
@@ -0,0 +1,63 @@
# Appearance: mode + named themes, and readable diff rows
## Requirement (from the user, 2026-09-23)
- `/settings` gets an **Appearance** section: mode **Dark / Light / System**, plus a named theme per mode.
- Themes: dark = **Default** (current Radix slate), **Darcula**, **Apple Dark**; light = **Default** (current ivory), **Apple Light**, **IntelliJ Light**, **Canvas** (linen).
- A theme also recolours code syntax (`--syn-*`).
- The sidebar's dark/light control stays; each mode remembers its own theme (dark=Darcula, light=Canvas → toggling swaps between those two).
- The diff's +/- rows in Code changes (`LaneDiffView`) get visibly more contrast, in every theme.
## Approach
Themes are pure CSS-variable overrides — every colour in the app already reads `--surface-*`, `--fg-*`, `--accent*`, `--status-*`, `--tint-base`, `--syn-*`. No component learns theme names.
1. **`client/src/index.css`**
- Light default block becomes `:root, [data-mode="light"]` (so a light preview can be scoped inside a dark page).
- After the `.dark` block, one block per theme: dark ones `.dark[data-theme="<id>"]`, light ones `[data-theme="<id>"]` (placed after the defaults, same specificity as `[data-mode=light]`, later wins). Each overrides only what differs; `--tint-base` always = its `--surface-3`.
- New `--diff-add` / `--diff-del` NOT needed: rows use existing `status-*` tokens.
2. **`client/src/hooks/useTheme.ts`** → module-level store read via `useSyncExternalStore`, so Settings and Sidebar never disagree.
- Storage: `theme` key now holds `dark|light|system` (old values stay valid → no migration); `agenthub.theme.dark` / `agenthub.theme.light` hold theme ids; unknown id ⇒ `default`.
- `system` resolves via `matchMedia("(prefers-color-scheme: dark)")` and follows its `change` event.
- Applies `.dark` class + `data-theme` attribute on `<html>` (attribute removed for `default`).
- Keeps today's API (`theme` = resolved `dark|light`, `setTheme`, `toggleTheme` — toggle sets the explicit opposite mode) and adds `mode`, `setMode`, `themeFor`, `setThemeFor(mode, id)`, plus exported `THEMES` registry `{ dark: [...ids], light: [...ids] }`.
3. **`client/src/pages/Settings.tsx`** — new first section `appearance` in `SETTINGS_SECTIONS`: a 3-button mode segment, then two grids (Dark themes / Light themes) of cards. Each card's swatch is a `<div data-mode|class=dark data-theme=id>` painting `bg-surface-0`, a `bg-surface-3` card, an accent dot and two syntax colours — the CSS vars re-scope inside it, so previews need zero duplicated hex. Selected card = accent ring + `aria-pressed`.
4. **`client/src/components/Sidebar.tsx`** — expanded panel's theme grid gains **System** (3 buttons, Monitor icon); collapsed toggle unchanged.
5. **`client/src/components/lanes/LaneDiffView.tsx`** `rowTint` → `bg-status-*/20` + a 3px inset left bar in the status colour; the `+`/`-` marker coloured `text-status-*` and bold. `highlight.ts`'s `add`/`del` classes use the same `/20` status tokens instead of fixed `emerald/red-500/10`.
6. **i18n** `en`/`vi`: `nav.themeNames.system`, `settings.appearance.*` (title, mode, darkThemes, lightThemes, theme names).
## Revisions after the plan challenge
1. Store keeps NO cached module state: `getSnapshot` reads localStorage + `matchMedia` and returns a primitive string; `subscribe` listens to a custom `agenthub-theme` event, `storage` (cross-tab) and the media-query `change`. No DOM work at import. DOM applied synchronously in the setter and in a `useLayoutEffect` (no one-frame flash).
2. `window.matchMedia?.(...)` guarded (jsdom lacks it); the system test installs its own listener-capable mock.
3. `<html>` carries `data-mode="dark|light"` too; light themes scoped `[data-mode="light"][data-theme=x]`, dark `.dark[data-theme=x]` — no source-order dependency.
4. `--tint-base: var(--surface-3)` and `--syn-plain: var(--fg-primary)` declared once in each default block; theme blocks never set them.
5. Diff emphasis `LaneDiffView.tsx:48` `bg-white/15` → `bg-fg-primary/15` (invisible on light otherwise).
6. Swatch root sets `text-fg-primary` + `bg-surface-0`.
7. Palettes pinned below; contrast checked with a small node script (white-on-accent, fg-muted on surface-3, status on its /20 chip) — dark themes held to the SAME bar the current dark default meets (its #0090ff is 3.3:1 under white text), light themes to ≥4.5:1.
8. Appearance section lives in its own component `client/src/components/settings/AppearanceSettings.tsx`; Settings.tsx gains only the section entry + render.
9. Sidebar: check the expanded panel width with 3 theme buttons; collapsed toggle moves System → the explicit opposite mode (documented).
## Palettes (hex; surface-0..5, border, border-light, accent, accent-hover, fg 1/2/muted, success/danger/warning; syntax)
- **darcula**: #2b2b2b #303234 #36383a #3c3f41 #45494a #4e5254 · #4b4f51 #5e6366 · #3592c4 #4aa3d6 · #dcdcdc #a9b7c6 #8c9296 · #6aab73 #ff6b68 #e0b25c · keyword #cc7832 string #6a8759 number #6897bb comment #808080 function #ffc66d property #9876aa tag #e8bf6a builtin #8888c6 operator/punct #a9b7c6 add #6aab73 del #ff6b68
- **apple-dark**: #161618 #1c1c1e #242426 #2c2c2e #363638 #3a3a3c · #38383a #48484a · #0a84ff #409cff · #f5f5f7 #aeaeb2 #8e8e93 · #30d158 #ff6961 #ffd60a · keyword #fc5fa3 string #fc6a5d number #d0bf69 comment #7f8c98 function #67b7a4 builtin #5dd8ff property #a167e6 tag #fc5fa3 operator/punct #a1a1a6 add #30d158 del #ff6961
- **apple-light**: #e9e9ee #efeff4 #f5f5f8 #fbfbfd #fdfdfe #ffffff · #d1d1d6 #e5e5ea · #0066cc #0055b3 · #1d1d1f #515154 #6e6e73 · #1f7a36 #c5000f #8a5a00 · keyword #9b2393 string #c41a16 number #1c00cf comment #5d6c79 function #326d74 builtin #0b4f79 property #3900a0 tag #9b2393 operator/punct #4b4b50 add #1f7a36 del #c5000f
- **intellij-light**: #eceef2 #f1f2f5 #f7f8fa #fdfdfe #ffffff #ffffff · #d3d5db #e4e6eb · #3574f0 #2e63cf · #1e1f22 #494b57 #6c707e · #1d7a35 #c01f2a #8f5f00 · keyword #0033b3 string #067d17 number #1750eb comment #707070 function #00627a builtin #000080 property #871094 tag #0033b3 operator/punct #4b4b4b add #067d17 del #c01f2a
- **canvas**: #e3dccd #e8e2d4 #ede8dc #f3efe6 #f7f4ed #faf8f3 · #cbbfa6 #dcd3c0 · #7a5230 #663f22 · #2b2620 #5e554a #6b6155 · #3f6a2a #9f2f23 #7d5700 · keyword #8a3b12 string #4d6b1f number #9a5b00 comment #7d7262 function #7a5a00 builtin #2f5e6e property #3b6660 tag #8e2f2f operator #8a3050 punct #6b6155 add #4d6b1f del #9f2f23
(Values that fail the contrast script are darkened/lightened until they pass; the final CSS is the source of truth.)
## Out of scope
- Server-side persistence (theme stays a per-browser preference, like lane order).
- ~~A no-flash inline boot script~~ — added after acceptance at the user's request (`client/index.html`).
- User-defined/custom themes.
- Surfaces that already bypass the CSS variables (they clash with today's light default too): the xterm terminal (no `theme`, TerminalView.tsx), Dashboard/workflow chart hardcoded hex (Dashboard.tsx, OrchestrationDAG.tsx, ErrorPropagationMap.tsx), ~415 raw Tailwind palette classes (toolStyle.ts, types.ts, …), `index.html`'s `text-gray-200` body.
## Tests
- `hooks/__tests__/useTheme.test.ts`: existing 4 stay green; add — `system` follows a mocked `matchMedia` and its change event; `setThemeFor` sets `data-theme` only for the active mode and persists; unknown stored id ⇒ default (no attribute); two hook instances stay in sync.
- Settings: a test that clicking a theme card sets `data-theme` / clicking System sets mode.
- Snapshot diff for Settings/Sidebar reviewed, then regenerated deliberately.
- `npm run test:client`, `tsc` via build, `check-headers.sh`. Manual: build + open `/settings`, screenshot each theme.
## Acceptance
- [ ] Mode Dark/Light/System works and persists across reload; System follows the OS live.
- [ ] Each of the 7 themes selectable; per-mode memory; sidebar toggle swaps between the two remembered themes.
- [ ] Code syntax recolours per theme.
- [ ] Diff +/- rows clearly distinguishable in every theme.
- [ ] Docs (client README / CLAUDE.md marker) updated.
+48 -9
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.8.2
version: 1.8.6
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -13,7 +13,7 @@ externalDocs:
description: Project documentation
url: https://git.smartgift.io.vn/Smartgift-AI/AgentHub#readme
servers:
- url: http://localhost:4820
- url: http://localhost:4830
description: Local dashboard server (default)
- url: http://127.0.0.1:4820
description: Local loopback endpoint used by hook-handler
@@ -3906,6 +3906,9 @@ components:
type: string
description: Title for the lane if one has to be created; ignored when one already exists.
example: App package
exact:
type: boolean
description: Only a lane on this very directory counts; a parent directory's lane is not returned, so a nested directory gets its own lane.
LaneDestructiveActionRequest:
type: object
required:
@@ -6196,6 +6199,7 @@ paths:
heapTotal: 71303168
heapUsed: 58392104
external: 3211884
heapLimit: 4345298944
arrayBuffers: 1048576
cpu_load:
- 2.13
@@ -10632,7 +10636,7 @@ paths:
tags:
- Lanes
summary: Find or adopt the lane owning a working directory
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
operationId: ensureLane
requestBody:
required: true
@@ -11039,7 +11043,7 @@ paths:
tags:
- Lanes
summary: The lane's recent commits
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
operationId: listLaneCommits
parameters:
- name: id
@@ -11060,6 +11064,8 @@ paths:
responses:
'200':
description: '{available, commits:[{sha, short, subject, author, date, refs}]}.'
'400':
description: EBADPATH (file= is absolute or has a `..` segment).
'404':
description: Lane not found.
/api/lanes/{id}/git/file:
@@ -11089,7 +11095,9 @@ paths:
'200':
description: '{available, path, rev, binary, truncated, lines}.'
'400':
description: EBADPATH, EBADREV or ENOFILE.
description: EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.
'403':
description: ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
'404':
description: Lane not found.
/api/lanes/{id}/git/ls:
@@ -11139,6 +11147,24 @@ paths:
description: EBADPATH (the path escapes the lane).
'404':
description: Lane not found.
/api/lanes/{id}/git/tree-all:
get:
tags:
- Lanes
summary: Every level of the tracked tree in one read
description: 'The file explorer''s expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory ("" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree''s entries. Read-only, no same-origin guard.'
operationId: listLaneTreeAll
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: '{available, levels:{<dir>:[{name, path, dir}]}, truncated}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/branches:
get:
tags:
@@ -11162,7 +11188,7 @@ paths:
tags:
- Lanes
summary: One git write on the lane's working copy
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch''s upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file''s `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git''s own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree''s git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
operationId: runLaneGitAction
parameters:
- name: id
@@ -11182,6 +11208,8 @@ paths:
- stage
- unstage
- discard
- stage-hunk
- discard-hunk
- commit
- switch
- branch-create
@@ -11215,18 +11243,29 @@ paths:
items:
type: string
description: stage, unstage, discard; each must be a path git reports changed.
path:
type: string
description: stage-hunk, discard-hunk; a path git reports changed (not untracked).
hunk:
type: string
description: stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.
context:
type: integer
description: stage-hunk, discard-hunk; the context the hunk was read at.
confirm:
type: boolean
description: discard only; the call is refused without it.
description: discard and discard-hunk; the call is refused without it.
responses:
'200':
description: '{action, ok:true, output} — git''s own stdout+stderr.'
'400':
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.
'404':
description: Lane not found.
'409':
description: EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.
'500':
description: git failed; its stderr is appended to error.message.
description: Unexpected server-side failure.
/api/lanes/{id}/{action}:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.8.2",
"version": "1.8.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.8.2",
"version": "1.8.6",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.8.2",
"version": "1.8.6",
"bin": {
"agenthub": "bin/agenthub.js"
},
+3 -1
View File
@@ -17,7 +17,9 @@ if [ -z "$MARKETPLACE" ]; then
echo "agenthub is not installed as a plugin (checkout install?) - nothing to update this way."
else
claude plugin marketplace update "$MARKETPLACE"
claude plugin update "agenthub@$MARKETPLACE" -y
# An older Claude Code has no `-y` (and no prompt for it to answer).
claude plugin update "agenthub@$MARKETPLACE" -y 2>/dev/null ||
claude plugin update "agenthub@$MARKETPLACE"
fi
```
+15
View File
@@ -83,6 +83,21 @@ function resolveClaude() {
}
function run(cmd, args, timeout) {
try {
return runOnce(cmd, args, timeout);
} catch (err) {
// An older Claude Code rejects `-y` ("unknown option") and never prompts,
// so the same command without the flag is the right retry.
if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err;
return runOnce(
cmd,
args.filter((a) => a !== "-y"),
timeout
);
}
}
function runOnce(cmd, args, timeout) {
log(`$ ${cmd} ${args.join(" ")}`);
const out = execFileSync(cmd, args, {
encoding: "utf8",
+15
View File
@@ -102,6 +102,21 @@ function findNewRoot(exclude) {
}
function run(cmd, args, timeout) {
try {
return runOnce(cmd, args, timeout);
} catch (err) {
// An older Claude Code rejects `-y` ("unknown option") and never prompts,
// so the same command without the flag is the right retry.
if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err;
return runOnce(
cmd,
args.filter((a) => a !== "-y"),
timeout
);
}
}
function runOnce(cmd, args, timeout) {
log(`$ ${cmd} ${args.join(" ")}`);
const out = execFileSync(cmd, args, {
encoding: "utf8",
+1 -1
View File
@@ -531,7 +531,7 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response lane starts as
+8
View File
@@ -1896,6 +1896,14 @@ describe("Transcript cache integration", () => {
assert.equal(res.body.server.version, pkg.version);
});
it("reports the V8 heap LIMIT, not just the currently allocated heapTotal", async () => {
const res = await fetch("/api/settings/info");
const { heapUsed, heapTotal, heapLimit } = res.body.server.memory;
assert.ok(Number.isFinite(heapLimit), "heapLimit should be a number");
// heapTotal is what V8 has grown to so far; the limit is the ceiling.
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
});
it("should evict cache entry on SessionEnd", async () => {
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
fs.writeFileSync(
+16
View File
@@ -116,6 +116,22 @@ describe("cc-plugin-cli argv construction", () => {
}
});
it("retries without -y on a Claude Code too old to know the flag", async () => {
// Older CLIs answer `error: unknown option '-y'` and exit 1; they also
// never prompt, so the same command without the flag is the right retry.
const restore = stubClaude(
'for a in "$@"; do [ "$a" = "-y" ] && { echo "error: unknown option \'-y\'" >&2; exit 1; }; done\n' +
'for a in "$@"; do echo "$a"; done'
);
try {
const r = await cli.pluginUpdate({ spec: "demo@m" });
assert.equal(r.ok, true);
assert.deepEqual(argvOf(r), ["plugin", "update", "demo@m"]);
} finally {
restore();
}
});
it("passes -y on install so a non-TTY run never waits for a prompt", async () => {
const restore = stubClaude();
try {
@@ -0,0 +1,639 @@
/**
* @file Regression tests for the lane git surface's hardening: the file viewer
* and untracked-file diff never read a secret file or follow a symlink out of
* the lane, `git status` paths survive C-quoting (non-ASCII, spaces, quotes),
* discarding a staged rename restores the original path, and untracked reads
* are async (no fs.*Sync on the poll path). Real git fixture, like
* git-diff-ops.test.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
let ROOT;
let LANE;
const g = (cwd, ...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
function write(dir, file, body) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
beforeEach(() => {
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-githarden-"));
LANE = path.join(ROOT, "lane");
g(ROOT, "init", "-q", "-b", "main", LANE);
g(LANE, "config", "user.email", "t@h");
g(LANE, "config", "user.name", "t");
write(LANE, "a.txt", "one\ntwo\n");
write(LANE, "old name.txt", "keep me\nplease\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "init");
write(ROOT, "outside.txt", "TOP SECRET OUTSIDE\n");
});
afterEach(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("readFileAt: secret files and symlink escapes", () => {
it("refuses .claude/settings.local.json at rev=work", async () => {
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
for (const p of [".claude/settings.local.json", "./.claude//settings.local.json"]) {
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" });
}
});
it("refuses the secret file at a committed revision too", async () => {
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
g(LANE, "add", "-f", ".claude/settings.local.json");
g(LANE, "commit", "-qm", "oops");
await assert.rejects(readFileAt(LANE, ".claude/settings.local.json", "HEAD"), {
code: "ESECRET",
});
});
it("refuses a symlink whose target is outside the lane", async () => {
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "link.txt"));
await assert.rejects(readFileAt(LANE, "link.txt", "work"), { code: "EBADPATH" });
});
it("refuses a symlink that points at the secret file", async () => {
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
fs.symlinkSync(".claude/settings.local.json", path.join(LANE, "innocent.json"));
await assert.rejects(readFileAt(LANE, "innocent.json", "work"), { code: "ESECRET" });
});
it("still reads a symlink that stays inside the lane", async () => {
fs.symlinkSync("a.txt", path.join(LANE, "alias.txt"));
const out = await readFileAt(LANE, "alias.txt", "work");
assert.deepEqual(out.lines, ["one", "two"]);
});
});
describe("collectChanges: untracked content reads", () => {
it("never shows the content of an untracked symlink pointing outside the lane", async () => {
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "escape.txt"));
const out = await collectChanges(LANE, { range: "work", full: true });
const entry = out.files.find((f) => f.path === "escape.txt");
assert.ok(entry, "the symlink is still listed");
assert.equal(JSON.stringify(entry).includes("TOP SECRET"), false);
});
it("never shows the secret file's content in a diff", async () => {
write(LANE, ".claude/settings.local.json", '{"k":"sk-old"}');
g(LANE, "add", "-f", ".claude/settings.local.json");
g(LANE, "commit", "-qm", "tracked by mistake");
write(LANE, ".claude/settings.local.json", '{"k":"sk-leak"}');
const out = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(out).includes("sk-leak"), false);
});
it("does not use synchronous fs reads", async () => {
write(LANE, "new.txt", "x\ny\n");
const saved = { readFileSync: fs.readFileSync, statSync: fs.statSync };
fs.readFileSync = () => {
throw new Error("sync read");
};
fs.statSync = () => {
throw new Error("sync stat");
};
let out;
try {
out = await collectChanges(LANE, { range: "work", full: true });
} finally {
Object.assign(fs, saved);
}
const entry = out.files.find((f) => f.path === "new.txt");
assert.equal(entry.added, 2);
assert.equal(entry.hunks[0].lines.length, 2);
});
});
describe("C-quoted status paths", () => {
const NAME = 'tài liệu "mới".txt';
it("collectChanges reports a non-ASCII/quoted path verbatim", async () => {
write(LANE, NAME, "xin chào\n");
write(LANE, "a.txt", "one\ntwo\nthree\n");
g(LANE, "mv", "old name.txt", "mới tên.txt");
const out = await collectChanges(LANE, { range: "work" });
const paths = out.files.map((f) => f.path);
assert.ok(paths.includes(NAME), paths.join("|"));
assert.ok(paths.includes("mới tên.txt"), paths.join("|"));
assert.equal(out.files.find((f) => f.path === NAME).added, 1);
assert.equal(out.files.find((f) => f.path === "a.txt").added, 1);
});
it("stage accepts a non-ASCII/quoted path", async () => {
write(LANE, NAME, "xin chào\n");
await runGitAction(LANE, "stage", { paths: [NAME] });
assert.match(g(LANE, "diff", "--cached", "--name-only", "-z"), /mới/);
});
it("branch range and commit range report renamed non-ASCII paths", async () => {
g(LANE, "checkout", "-qb", "feat");
g(LANE, "mv", "old name.txt", "mới tên.txt");
g(LANE, "commit", "-qm", "rename");
const branch = await collectChanges(LANE, { range: "branch", base: "main" });
assert.deepEqual(
branch.files.map((f) => f.path),
["mới tên.txt"]
);
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD" });
assert.deepEqual(
commit.files.map((f) => f.path),
["mới tên.txt"]
);
});
});
describe("discard of a staged rename", () => {
it("restores the original path and removes the new one", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
await runGitAction(LANE, "discard", { paths: ["new name.txt"], confirm: true });
assert.equal(fs.readFileSync(path.join(LANE, "old name.txt"), "utf8"), "keep me\nplease\n");
assert.equal(fs.existsSync(path.join(LANE, "new name.txt")), false);
assert.equal(g(LANE, "status", "--porcelain"), "");
});
it("still requires confirm: true", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
await assert.rejects(runGitAction(LANE, "discard", { paths: ["new name.txt"] }), {
code: "ECONFIRM",
});
});
});
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
it("answers 403 for the router's settings file", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const server = await startServer(createApp(), 0);
const base = `http://127.0.0.1:${server.address().port}`;
try {
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
const created = await fetch(`${base}/api/lanes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd: LANE, title: "secret" }),
}).then((r) => r.json());
const id = created.lane.id;
const r = await fetch(
`${base}/api/lanes/${id}/git/file?path=.claude/settings.local.json&rev=work`
);
assert.equal(r.status, 403);
const body = await r.json();
assert.equal(body.error.code, "ESECRET");
assert.equal(JSON.stringify(body).includes("sk-x"), false);
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
} finally {
server.close();
}
});
});
describe("unstage of a staged rename", () => {
it("unstages both halves: nothing staged, the new file left untracked, the old one deleted in the tree only", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
await runGitAction(LANE, "unstage", { paths: ["new name.txt"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "");
assert.equal(fs.readFileSync(path.join(LANE, "new name.txt"), "utf8"), "keep me\nplease\n");
});
});
describe("readFileAt: one lexical guard for every rev", () => {
let SUB;
beforeEach(() => {
write(LANE, ".claude/settings.local.json", '{"k":"sk-parent"}');
write(LANE, "root-only.txt", "ROOT CONTENT\n");
write(LANE, "pkg/in.txt", "inside\n");
write(LANE, "pkg/.claude/settings.local.json", '{"k":"sk-sub"}');
g(LANE, "add", "-f", "-A");
g(LANE, "commit", "-qm", "sub");
SUB = path.join(LANE, "pkg");
});
it("refuses ../ and absolute paths at a committed rev in a subdirectory lane", async () => {
for (const p of [
"../.claude/settings.local.json",
"./../root-only.txt",
"x/../../root-only.txt",
"/etc/passwd",
]) {
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "EBADPATH" }, p);
}
});
it("reads committed paths relative to the lane, not the repo root", async () => {
const out = await readFileAt(SUB, "./in.txt", "HEAD");
assert.deepEqual(out.lines, ["inside"]);
assert.equal(out.path, "in.txt");
// A repo-root path is NOT reachable by naming it without ../
await assert.rejects(readFileAt(SUB, "root-only.txt", "HEAD"), { code: "ENOFILE" });
});
it("refuses the lane's own secret file in its normalised spellings", async () => {
for (const p of ["./.claude/settings.local.json", ".claude//settings.local.json"]) {
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "ESECRET" }, p);
}
});
});
describe("paths are literal, never pathspec patterns", () => {
it("discarding an untracked 'new*.js' deletes only that file", async () => {
for (const f of ["new*.js", "new1.js", "new2.js"]) write(LANE, f, "x\n");
await runGitAction(LANE, "discard", { paths: ["new*.js"], confirm: true });
assert.equal(fs.existsSync(path.join(LANE, "new*.js")), false);
assert.equal(fs.existsSync(path.join(LANE, "new1.js")), true);
assert.equal(fs.existsSync(path.join(LANE, "new2.js")), true);
});
it("staging 'app/[id]/page.js' does not stage app/i/page.js", async () => {
write(LANE, "app/[id]/page.js", "a\n");
write(LANE, "app/i/page.js", "b\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "app");
write(LANE, "app/[id]/page.js", "a2\n");
write(LANE, "app/i/page.js", "b2\n");
await runGitAction(LANE, "stage", { paths: ["app/[id]/page.js"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "app/[id]/page.js\n");
});
it("a single-file diff of 'a[b].txt' carries only that file's hunks", async () => {
write(LANE, "a[b].txt", "one\n");
write(LANE, "ab.txt", "one\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "brackets");
write(LANE, "a[b].txt", "one\nmine\n");
write(LANE, "ab.txt", "one\nOTHER\n");
const out = await collectChanges(LANE, { range: "work", file: "a[b].txt" });
assert.equal(JSON.stringify(out.files[0].hunks).includes("OTHER"), false);
assert.equal(out.files[0].hunks.length, 1);
});
});
describe("a lane whose cwd is a subdirectory of its repo", () => {
let SUB;
beforeEach(() => {
write(LANE, "pkg/in.txt", "one\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "pkg");
SUB = path.join(LANE, "pkg");
write(LANE, "a.txt", "one\ntwo\nOUTSIDE EDIT\n");
write(LANE, "outside-new.txt", "no\n");
write(SUB, "in.txt", "one\ntwo\n");
write(SUB, "fresh.txt", "a\nb\nc\n");
});
it("lists only its own changes, lane-relative, with hunks", async () => {
const out = await collectChanges(SUB, { range: "work", full: true });
assert.deepEqual(
out.files.map((f) => f.path),
["fresh.txt", "in.txt"]
);
const tracked = out.files.find((f) => f.path === "in.txt");
assert.equal(tracked.added, 1);
assert.equal(tracked.hunks.length, 1);
const fresh = out.files.find((f) => f.path === "fresh.txt");
assert.equal(fresh.added, 3);
assert.equal(fresh.hunks[0].lines.length, 3);
});
it("branch and commit ranges are lane-relative and lane-scoped too", async () => {
g(LANE, "checkout", "-qb", "feat");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "both");
const branch = await collectChanges(SUB, { range: "branch", base: "main", full: true });
assert.deepEqual(
branch.files.map((f) => f.path),
["fresh.txt", "in.txt"]
);
assert.equal(branch.files.find((f) => f.path === "in.txt").added, 1);
const commit = await collectChanges(SUB, { range: "commit", sha: "HEAD", file: "in.txt" });
assert.equal(commit.files[0].hunks.length, 1);
});
it("stage, unstage and discard work on lane-relative paths", async () => {
await runGitAction(SUB, "stage", { paths: ["in.txt", "fresh.txt"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\npkg/in.txt\n");
await runGitAction(SUB, "unstage", { paths: ["in.txt"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\n");
await runGitAction(SUB, "discard", { paths: ["in.txt"], confirm: true });
assert.equal(fs.readFileSync(path.join(SUB, "in.txt"), "utf8"), "one\n");
});
it("cannot name a change outside its cwd", async () => {
for (const p of ["../a.txt", "a.txt", "../outside-new.txt"]) {
await assert.rejects(
runGitAction(SUB, "discard", { paths: [p], confirm: true }),
{ code: "EBADPATH" },
p
);
}
assert.equal(fs.existsSync(path.join(LANE, "outside-new.txt")), true);
});
it("gitFacts (the lane card) counts and lists only the lane's own files, cwd-relative", async () => {
write(SUB, "mới tên.txt", "x\n");
const facts = await gitFacts(SUB);
const paths = facts.files.map((f) => f.path).sort();
assert.deepEqual(paths, ["fresh.txt", "in.txt", "mới tên.txt"]);
assert.equal(facts.dirty, 1);
assert.equal(facts.untracked, 2);
assert.equal(facts.files.find((f) => f.path === "in.txt").added, 1);
});
it("commit sweeps in only the lane's own files", async () => {
await runGitAction(SUB, "commit", { message: "lane only" });
assert.equal(
g(LANE, "show", "--name-only", "--format=", "HEAD"),
"pkg/fresh.txt\npkg/in.txt\n"
);
assert.match(g(LANE, "status", "--porcelain"), /a\.txt/);
});
it("refuses to commit while something OUTSIDE the lane is staged, and leaves it staged", async () => {
const before = g(LANE, "rev-parse", "HEAD");
g(LANE, "add", "a.txt");
for (const only of [undefined, "staged"]) {
g(SUB, "add", "in.txt");
await assert.rejects(runGitAction(SUB, "commit", { message: "lane only", only }), {
code: "ESTAGEDOUTSIDE",
});
}
assert.match(g(LANE, "diff", "--cached", "--name-only"), /^a\.txt$/m);
assert.equal(g(LANE, "rev-parse", "HEAD"), before);
});
});
describe("reads larger than the git() buffer report truncated, not missing", () => {
const BIG = `${"x".repeat(99)}\n`.repeat(100_000); // 10 MB
it("readFileAt at a rev", async () => {
write(LANE, "big.txt", BIG);
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "big");
const out = await readFileAt(LANE, "big.txt", "HEAD");
assert.equal(out.truncated, true);
assert.ok(out.lines.length > 1000);
});
it("a single-file diff", async () => {
write(LANE, "big.txt", "seed\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "seed");
write(LANE, "big.txt", BIG);
const out = await collectChanges(LANE, { range: "work", file: "big.txt" });
assert.equal(out.files[0].truncated, true);
assert.ok(out.files[0].hunks[0].lines.length > 100);
});
});
describe("loop 2: secret file at any depth, symlinked .claude", () => {
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
it("a root lane cannot read a subdir lane's settings.local.json (work, rev, hunks)", async () => {
write(LANE, "sub/.claude/settings.local.json", KEY);
for (const p of ["sub/.claude/settings.local.json", "SUB/.Claude/Settings.Local.json"]) {
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" }, p);
}
const untracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
g(LANE, "add", "-f", "sub/.claude/settings.local.json");
g(LANE, "commit", "-qm", "oops");
await assert.rejects(readFileAt(LANE, "sub/.claude/settings.local.json", "HEAD"), {
code: "ESECRET",
});
write(LANE, "sub/.claude/settings.local.json", KEY.replace("deep", "deep2"));
const tracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD", full: true });
assert.equal(JSON.stringify(commit).includes("sk-deep"), false);
});
it("a symlinked .claude does not expose the key under its target's name", async () => {
write(LANE, "conf/settings.local.json", KEY);
fs.symlinkSync("conf", path.join(LANE, ".claude"));
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "work"), {
code: "ESECRET",
});
const untracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
g(LANE, "add", "conf/settings.local.json");
g(LANE, "commit", "-qm", "conf");
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "HEAD"), {
code: "ESECRET",
});
write(LANE, "conf/settings.local.json", KEY.replace("deep", "deep2"));
const tracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
});
});
describe("loop 2: subdir lane boundaries", () => {
let SUB;
beforeEach(() => {
write(LANE, "pkg/in.txt", "one\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "pkg");
SUB = path.join(LANE, "pkg");
});
it("listCommits refuses a ?file= outside the lane", async () => {
const { listCommits } = require("../lib/git-diff");
for (const p of ["../a.txt", "/etc/passwd"]) {
await assert.rejects(listCommits(SUB, { file: p }), { code: "EBADPATH" }, p);
}
assert.equal((await listCommits(SUB, { file: "in.txt" })).length, 1);
});
it("commit (all) in a clean subdir lane of a dirty repo is ENOCHANGES", async () => {
write(LANE, "a.txt", "dirty outside\n");
await assert.rejects(runGitAction(SUB, "commit", { message: "x" }), { code: "ENOCHANGES" });
});
});
describe("loop 2: readFileAt rev=work reads at most the cap", () => {
it("never loads a whole huge file, still reports truncated", async () => {
write(LANE, "huge.txt", `${"y".repeat(99)}\n`.repeat(30_000)); // 3 MB
const orig = fs.promises.readFile;
fs.promises.readFile = async () => {
throw new Error("whole-file read");
};
try {
const out = await readFileAt(LANE, "huge.txt", "work");
assert.equal(out.truncated, true);
assert.ok(out.lines.length > 1000);
assert.ok(out.lines.join("\n").length <= 1_000_000);
} finally {
fs.promises.readFile = orig;
}
const small = await readFileAt(LANE, "a.txt", "work");
assert.deepEqual(small.lines, ["one", "two"]);
assert.equal(small.truncated, false);
});
});
describe("loop 2: push picks the right remote", () => {
const bare = (name) => {
const dir = path.join(ROOT, `${name}.git`);
g(ROOT, "init", "-q", "--bare", dir);
return dir;
};
const head = () => g(LANE, "rev-parse", "HEAD").trim();
it("pushes to the sole remote even when it is not origin", async () => {
const dir = bare("only");
g(LANE, "remote", "add", "backup", dir);
await runGitAction(LANE, "push");
assert.equal(g(dir, "rev-parse", "main").trim(), head());
});
it("pushes to the branch's upstream remote over origin", async () => {
const o = bare("o");
const u = bare("u");
g(LANE, "remote", "add", "origin", o);
g(LANE, "remote", "add", "fork", u);
g(LANE, "push", "-q", "-u", "fork", "HEAD");
write(LANE, "a.txt", "new\n");
g(LANE, "commit", "-qam", "next");
await runGitAction(LANE, "push");
assert.equal(g(u, "rev-parse", "main").trim(), head());
assert.throws(() => g(o, "rev-parse", "--verify", "-q", "main"));
});
it("falls back to origin among several remotes", async () => {
const o = bare("o");
g(LANE, "remote", "add", "fork", bare("f"));
g(LANE, "remote", "add", "origin", o);
await runGitAction(LANE, "push");
assert.equal(g(o, "rev-parse", "main").trim(), head());
});
it("ENOREMOTE with no remote, or several and no origin", async () => {
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
g(LANE, "remote", "add", "a", bare("a"));
g(LANE, "remote", "add", "b", bare("b"));
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
});
});
describe("loop 2: a claude and a codex lane on one cwd share one git-write lock", () => {
it("concurrent writes from both lanes never collide on index.lock", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api2.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const server = await startServer(createApp(), 0);
const base = `http://127.0.0.1:${server.address().port}`;
const post = (url, body) =>
fetch(`${base}${url}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
try {
const ids = [];
for (const tool of ["claude", "codex"]) {
const r = await post("/api/lanes/ensure", { cwd: LANE, tool, title: tool }).then((x) =>
x.json()
);
ids.push(r.lane.id);
}
assert.notEqual(ids[0], ids[1]);
// A clean filter runs while `git add` HOLDS index.lock, so a write
// from the sibling lane in that window fails unless serialized.
g(LANE, "config", "filter.slow.clean", "sleep 1; cat");
write(LANE, ".gitattributes", "slow.txt filter=slow\n");
write(LANE, "slow.txt", "s\n");
write(LANE, "b.txt", "new\n");
const slow = post(`/api/lanes/${ids[0]}/git/stage`, { paths: ["slow.txt"] });
await new Promise((r) => setTimeout(r, 400));
const other = await post(`/api/lanes/${ids[1]}/git/stage`, { paths: ["b.txt"] });
const first = await slow;
assert.equal(first.status, 200, await first.text());
assert.equal(other.status, 200, await other.text());
} finally {
server.close();
}
});
});
describe("loop 3: a subdirectory lane's key behind its own symlinked .claude", () => {
it("is refused to a root lane by its target name, on disk and at a committed rev", async () => {
write(LANE, "sub/conf/settings.local.json", '{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-SUB"}}');
fs.symlinkSync("conf", path.join(LANE, "sub/.claude"));
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "work"), {
code: "ESECRET",
});
g(LANE, "add", "-f", "sub/conf/settings.local.json");
g(LANE, "commit", "-qm", "oops");
fs.rmSync(path.join(LANE, "sub"), { recursive: true });
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "HEAD"), {
code: "ESECRET",
});
});
});
describe("loop 3: a staged rename INTO a subdirectory lane", () => {
it("is refused by ESTAGEDOUTSIDE, since it also deletes the old path outside the lane", async () => {
write(LANE, "shared/x.js", "export const x = 1;\n");
write(LANE, "pkg/keep.txt", "k\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "base");
g(LANE, "mv", "shared/x.js", "pkg/x.js");
await assert.rejects(
runGitAction(path.join(LANE, "pkg"), "commit", { message: "move", only: "staged" }),
{ code: "ESTAGEDOUTSIDE" }
);
});
});
describe("one numstat parser for the Changes panel and the lane card", () => {
it("keys a rename by its new path and a binary file with null counts", () => {
// Real `git diff --numstat -z` shapes: a rename is "a\td\t\0old\0new\0",
// a binary file is "-\t-\tpath\0". `git show --format=` adds a leading \n.
const stats = parseNumstat("\n1\t2\t\0old name.txt\0new name.txt\0-\t-\tlogo.png\0");
assert.deepEqual([...stats.keys()], ["new name.txt", "logo.png"]);
assert.deepEqual(stats.get("new name.txt"), { added: 1, deleted: 2, binary: false });
assert.deepEqual(stats.get("logo.png"), { added: null, deleted: null, binary: true });
});
it("gitFacts lists a staged rename and a binary file with the same counts, no extra field", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
write(LANE, "new name.txt", "keep me\nplease\nmore\n");
fs.writeFileSync(path.join(LANE, "logo.png"), Buffer.from([0, 1, 2, 0, 255]));
g(LANE, "add", "-A");
const { files } = await gitFacts(LANE);
const byPath = Object.fromEntries(files.map((f) => [f.path, f]));
assert.deepEqual(byPath["new name.txt"], {
path: "new name.txt",
status: "R ",
added: 1,
deleted: 0,
});
assert.deepEqual(byPath["logo.png"], {
path: "logo.png",
status: "A ",
added: null,
deleted: null,
});
});
});
+81
View File
@@ -19,6 +19,7 @@ const {
listCommits,
listPaths,
listTree,
listTreeAll,
parseUnifiedDiff,
readFileAt,
} = require("../lib/git-diff");
@@ -456,6 +457,22 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
);
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
});
it("reads every level of the tree in one pass, grouped like listTree", async () => {
write(LANE, "src/api/x.ts", "x\n");
write(LANE, "src/main.ts", "m\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
const all = await listTreeAll(LANE);
assert.equal(all.truncated, false);
// Each level equals what the one-level read returns for it.
for (const level of ["", "src", "src/api"]) {
const one = await listTree(LANE, { subdir: level });
assert.deepEqual(all.levels[level], one.entries, level);
}
assert.deepEqual(Object.keys(all.levels).sort(), ["", "src", "src/api"]);
});
});
describe("runGitAction — per-file staging, discard and branches", () => {
@@ -552,3 +569,67 @@ describe("runGitAction — per-file staging, discard and branches", () => {
});
});
});
describe("runGitAction — one hunk at a time", () => {
beforeEach(freshFixture);
/** A 20-line file edited at both ends: two hunks at the default context. */
function twoHunks() {
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
write(LANE, "big.txt", lines.join("\n") + "\n");
gc(LANE, "add", "big.txt");
gc(LANE, "commit", "-qm", "big");
lines[0] = "FIRST";
lines[19] = "LAST";
write(LANE, "big.txt", lines.join("\n") + "\n");
}
const headers = async () =>
(await collectChanges(LANE, { range: "work", file: "big.txt" })).files[0].hunks.map(
(h) => h.header
);
it("stages ONE hunk, leaving the other in the working tree", async () => {
twoHunks();
const [first] = await headers();
await runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: first });
const staged = g(LANE, "diff", "--cached", "--", "big.txt");
const unstaged = g(LANE, "diff", "--", "big.txt");
assert.match(staged, /\+FIRST/);
assert.doesNotMatch(staged, /LAST/);
assert.match(unstaged, /\+LAST/);
assert.doesNotMatch(unstaged, /FIRST/);
});
it("undoes ONE hunk in the working tree, only with confirm", async () => {
twoHunks();
const [, second] = await headers();
await assert.rejects(
() => runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second }),
{ code: "ECONFIRM" }
);
await runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second, confirm: true });
const text = fs.readFileSync(path.join(LANE, "big.txt"), "utf8");
assert.match(text, /^FIRST\n/);
assert.match(text, /line 20\n$/);
});
it("refuses a hunk that is no longer in the diff, and a path that is not changed", async () => {
twoHunks();
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: "@@ -1,9 +1,9 @@" }),
{ code: "EHUNKGONE" }
);
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "keep.txt", hunk: "@@ -1 +1 @@" }),
{ code: "EBADPATH" }
);
});
it("sends an untracked file to the per-file actions", async () => {
write(LANE, "new.txt", "x\n");
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "new.txt", hunk: "@@ -0,0 +1,1 @@" }),
{ code: "EUNTRACKED" }
);
});
});
+15
View File
@@ -1164,6 +1164,21 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.notEqual(sibling.body.lane.id, owner.body.lane.id);
});
it("with exact:true, creates a lane for a nested cwd instead of returning its parent (#42)", async () => {
const parent = path.join(ROOT, "ensure-exact-parent");
const nested = path.join(parent, "services");
const owner = await request("POST", "/api/lanes/ensure", { cwd: parent });
assert.equal(owner.status, 201);
const r = await request("POST", "/api/lanes/ensure", { cwd: nested, exact: true });
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.created, true);
assert.equal(r.body.lane.cwd, nested);
// The same directory still answers with its own lane, never a second one.
const again = await request("POST", "/api/lanes/ensure", { cwd: nested, exact: true });
assert.equal(again.body.created, false);
assert.equal(again.body.lane.id, r.body.lane.id);
});
it("yields exactly one lane for two concurrent ensure calls on the same path", async () => {
const cwd = path.join(ROOT, "ensure-concurrent");
const [a, b] = await Promise.all([
+16
View File
@@ -1022,6 +1022,22 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
assert.equal(g(dir, "branch", "--list", "feat/sibling").trim(), "");
});
it("maps a git exit status to 409 EGIT with git's stderr in the message", async () => {
// A remote that is not a repository: `git push` exits 128, and execFile
// reports that as a NUMERIC err.code. It must never reach the client as
// `code: 128`, nor as a 500 — git refusing is not a server fault.
g(dir, "remote", "add", "origin", path.join(ROOT, "no-such-remote"));
try {
const r = await request("POST", `/api/lanes/${laneId}/git/push`, {});
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EGIT");
assert.match(r.body.error.message, /^git push failed\n/);
assert.match(r.body.error.message, /no-such-remote/);
} finally {
g(dir, "remote", "remove", "origin");
}
});
it("404s every git route for a lane that does not exist", async () => {
for (const url of [
"/api/lanes/999999/git/changes",
+17 -1
View File
@@ -130,7 +130,23 @@ function serialize(fn) {
* @param {{timeoutMs?:number}} [opts]
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
*/
function runClaude(args, opts = {}) {
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
// It also predates the confirmation prompt the flag answers, so the same
// command without it is the correct retry, not a weaker one.
const NO_YES_FLAG = /unknown option '-y'/;
async function runClaude(args, opts = {}) {
const result = await runClaudeOnce(args, opts);
if (!result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
return runClaudeOnce(
args.filter((a) => a !== "-y"),
opts
);
}
return result;
}
function runClaudeOnce(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
+344 -69
View File
@@ -16,13 +16,39 @@
* total) because this rides an HTTP response a browser has to render: a lane
* mid-`npm install` or holding a committed lockfile would otherwise ship
* megabytes of patch to a React list. A cap that bites sets `truncated`.
*
* The lane's cwd is the boundary, even when it is a SUBDIRECTORY of its repo:
* every path in and out is cwd-relative, status entries outside the cwd are
* dropped, and every git call treats a path as a literal file name, never a
* pathspec glob (`GIT_LITERAL_PATHSPECS`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { git } = require("./worktree");
const worktree = require("./worktree");
/** Every git call here: argv only, and a path argument is a literal name. */
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/**
* A git READ whose output this module caps itself: the buffer is sized past
* the cap (x4, a UTF-8 char is at most 4 bytes, plus headroom) so git's own
* buffer can never be what fails first, and an overflow comes back as the
* partial output — longer than `capChars`, so the caller's cap marks it
* truncated — instead of an error read as "missing".
*/
async function readCapped(dir, args, capChars) {
try {
return (await git(dir, args, { maxBuffer: capChars * 4 + 64 * 1024 })).stdout;
} catch (err) {
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && err.git?.stdout) {
return String(err.git.stdout);
}
throw err;
}
}
/** Most files one response names. Beyond this the list is `truncated`. */
const MAX_FILES = 300;
@@ -36,6 +62,8 @@ const MAX_UNTRACKED_BYTES = 1_000_000;
const MAX_FILE_VIEW_BYTES = 1_000_000;
/** Most paths `listPaths`/`listTree` answer with. */
const MAX_PATHS = 500;
/** Characters of `ls-files -z` read before the listing counts as truncated. */
const MAX_LS_CHARS = 16 * 1024 * 1024;
/** Lines of unchanged context around a hunk, and the ceiling "expand all"
* is clamped to — a whole large file rendered as context is the one way this
* panel can be made to ship more bytes than the caps above allow. */
@@ -47,6 +75,104 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
* ref name without the characters that would let it read as an option or a
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
/** The file whose CONTENT this module never returns, at any rev and at ANY
* depth: `settings.local.json` is where router-apply.js writes a lane's AI
* Router API key, and key material never leaves the server. Any depth,
* because a lane at the repo root sees a subdirectory lane's key as
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
* filesystem (macOS) opens it under any casing. */
const SECRET_FILE = ".claude/settings.local.json";
const SECRET_BASENAME = path.posix.basename(SECRET_FILE);
/** Whether a lane-relative path names a secret file, however it is spelled. */
function isSecret(rel) {
const norm = path.posix
.normalize(String(rel).replace(/\\/g, "/"))
.replace(/^\.\//, "")
.toLowerCase();
// By BASENAME, in any directory: a `.claude` symlinked to `conf/` stores the
// key as `conf/settings.local.json`, and a lane above that one (or a commit
// of that target) never spells it through `.claude/`. A file with this name
// is Claude Code's local settings in practice, so nothing legitimate is lost.
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
}
/**
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
* `conf/settings.local.json`, which no name check catches. Secret when the
* real path, relative to the lane's real root, is one by `isSecret`, or when
* it is the very file `<lane>/.claude/settings.local.json` resolves to. A
* path that does not exist on disk is judged by its name alone.
*/
async function isSecretOnDisk(dir, file) {
if (isSecret(file)) return true;
let root;
let real;
try {
root = await fs.promises.realpath(dir);
real = await fs.promises.realpath(path.resolve(root, file));
} catch {
return false;
}
if (isSecret(path.relative(root, real))) return true;
try {
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
} catch {
return false;
}
}
/**
* The ONE lexical guard for a caller-supplied path, at every rev: a relative
* path with no `..` segment, normalised (`./a//b` → `a/b`) so the secret
* check and git both see the lane-relative spelling. Throws EBADPATH.
*/
function lanePath(file) {
if (typeof file !== "string" || !file || file.includes("\0")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
}
// A backslash is an ordinary file-name byte to git on POSIX, not a separator.
if (file.startsWith("/") || file.split("/").includes("..")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
}
const norm = path.posix
.normalize(file)
.replace(/^(\.\/)+/, "")
.replace(/\/+$/, "");
if (!norm || norm === ".") throw badRequest("EBADPATH", "path must name a file");
return norm;
}
/**
* Resolve `file` inside the lane for a CONTENT read: realpath-confined to the
* lane's own realpath, so neither `..` nor a symlink can walk out of it, and
* refused when either the requested or the resolved path is a secret file.
* Throws EBADPATH / ESECRET / ENOFILE.
*/
async function resolveInLane(dir, file) {
if (isSecret(file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
const escapes = (base, target) => {
const r = path.relative(base, target);
return !r || r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r);
};
// Lexically first, so `../x` is EBADPATH whether or not x exists.
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
throw badRequest("EBADPATH", "path escapes the lane directory");
}
const root = await fs.promises.realpath(dir);
let real;
try {
real = await fs.promises.realpath(path.resolve(root, file));
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the lane directory");
if (await isSecretOnDisk(root, file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
return real;
}
/** Throw a coded error the routes map onto a 400. */
function badRequest(code, message) {
@@ -131,15 +257,26 @@ async function resolveBaseRef(dir, base) {
return null;
}
/** `git diff --numstat` output → Map<path, {added, deleted, binary}>. */
/**
* `git diff --numstat -z` output → Map<path, {added, deleted, binary}>. `-z`
* because without it git C-quotes any path with a space, a quote or a
* non-ASCII byte (`"t\303\240i.txt"`), which then keys nothing. A rename is
* `a\td\t\0old\0new\0`; the path that exists now is the key. Shared with
* worktree.js's `laneFiles` (the lane card), so the card and the Changes panel
* count a file the same way.
*/
function parseNumstat(stdout) {
const stats = new Map();
for (const line of stdout.split("\n")) {
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const m = tokens[i].replace(/^\n/, "").match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
if (!m) continue;
// A rename reports "old => new" (or "dir/{a => b}/file"); the path that
// exists now is what every other source here is keyed by.
const file = m[3].includes(" => ") ? renameTarget(m[3]) : m[3];
let file = m[3];
if (!file) {
file = tokens[i + 2];
i += 2;
}
if (!file) continue;
stats.set(file, {
added: m[1] === "-" ? null : Number(m[1]),
deleted: m[2] === "-" ? null : Number(m[2]),
@@ -149,11 +286,48 @@ function parseNumstat(stdout) {
return stats;
}
/** `dir/{old => new}/f.js` or `old.js => new.js` → the post-rename path. */
function renameTarget(spec) {
const braced = spec.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/\//g, "/");
return spec.split(" => ").pop();
/**
* `git status --porcelain=v1 -z` → [{code, path, from}]. Each record is
* `XY path\0`; a rename/copy carries its ORIGINAL path as the next record.
* Shared with git-ops.js so the paths a write may name are the paths shown.
*/
function parsePorcelainZ(stdout) {
const entries = [];
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const rec = tokens[i];
if (rec.length < 4) continue;
const code = rec.slice(0, 2);
const entry = { code, path: rec.slice(3), from: null };
if (code[0] === "R" || code[0] === "C" || code[1] === "R" || code[1] === "C") {
entry.from = tokens[++i] || null;
}
entries.push(entry);
}
return entries;
}
/**
* `git status` for the LANE: porcelain paths are always repo-ROOT-relative,
* while every pathspec and every fs read here resolves from the lane's cwd.
* So entries are cut down to the ones inside the cwd and re-spelled relative
* to it — a subdirectory lane neither shows nor can name a change outside
* itself. A rename whose original path is outside keeps `from: null`.
* Shared with git-ops.js so the paths a write may name are the paths shown.
*/
async function laneStatus(dir) {
const [status, prefix] = await Promise.all([
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]),
git(dir, ["rev-parse", "--show-prefix"]),
]);
const pre = prefix.stdout.trim();
const inside = (p) => (p && p.startsWith(pre) ? p.slice(pre.length) || null : null);
const entries = [];
for (const e of parsePorcelainZ(status.stdout)) {
const rel = inside(e.path);
if (rel) entries.push({ code: e.code, path: rel, from: inside(e.from) });
}
return entries;
}
/**
@@ -165,22 +339,20 @@ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
/** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */
async function summarizeWork(dir) {
const [status, numstat] = await Promise.all([
git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]),
git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ({ stdout: "" })),
const [entries, numstat] = await Promise.all([
laneStatus(dir),
// --relative: cwd-scoped, cwd-relative paths, so they key the status ones.
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })),
]);
const stats = parseNumstat(numstat.stdout);
const files = [];
for (const line of status.stdout.split("\n")) {
if (!line.trim()) continue;
const code = line.slice(0, 2);
const raw = line.slice(3).trimEnd();
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
for (const { code, path: file } of entries) {
const untracked = code === "??";
const stat = stats.get(file);
// One read per untracked file, not two: its line count and its
// binary-ness are the same buffer answered twice.
const fresh = untracked ? untrackedFacts(dir, file) : null;
// Sequential on purpose: `normal` folds untracked dirs, so this is short.
const fresh = untracked ? await untrackedFacts(dir, file) : null;
files.push({
path: file,
status: code,
@@ -208,11 +380,12 @@ async function summarizeWork(dir) {
* everywhere else in this response. A file over the cap counts as binary so
* nothing downstream tries to render it.
*/
function untrackedFacts(dir, file) {
async function untrackedFacts(dir, file) {
try {
const abs = path.join(dir, file);
if (fs.statSync(abs).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true };
const buf = fs.readFileSync(abs);
const abs = await resolveInLane(dir, file);
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
return { lines: null, binary: true };
const buf = await fs.promises.readFile(abs);
if (looksBinary(buf)) return { lines: null, binary: true };
const text = buf.toString("utf8");
if (!text) return { lines: 0, binary: false };
@@ -220,7 +393,8 @@ function untrackedFacts(dir, file) {
return { lines, binary: false };
} catch {
// A directory entry (git reports untracked DIRS with a trailing slash), a
// dangling symlink, a file deleted between the status call and this read.
// dangling symlink, a file deleted between the status call and this read,
// or one resolveInLane refuses (a secret, a symlink out of the lane).
return { lines: null, binary: false };
}
}
@@ -229,8 +403,8 @@ function untrackedFacts(dir, file) {
async function summarizeBranch(dir, baseRef) {
const spec = `${baseRef}...HEAD`;
const [nameStatus, numstat] = await Promise.all([
git(dir, ["diff", "--name-status", spec]),
git(dir, ["diff", "--numstat", spec]),
git(dir, ["diff", "--name-status", "-z", "--relative", spec]),
git(dir, ["diff", "--numstat", "-z", "--relative", spec]),
]);
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
}
@@ -242,24 +416,26 @@ async function summarizeBranch(dir, baseRef) {
* a reader scrolling a branch's history.
*/
async function summarizeCommit(dir, sha) {
const base = ["show", "--format=", "-m", "--first-parent", sha];
const base = ["show", "--format=", "-m", "--first-parent", "--relative", sha];
const [nameStatus, numstat] = await Promise.all([
git(dir, [...base, "--name-status"]),
git(dir, [...base, "--numstat"]),
git(dir, [...base, "--name-status", "-z"]),
git(dir, [...base, "--numstat", "-z"]),
]);
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
}
/** Shared `--name-status` reader for the ranges that have no working tree. */
/** Shared `--name-status -z` reader for the ranges that have no working tree. */
function parseNameStatus(stdout, stats) {
const files = [];
const seen = new Set();
for (const line of stdout.split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
const letter = parts[0][0];
// A rename/copy line is "R100\told\tnew" — the new path is the live one.
const file = parts.length > 2 ? parts[2] : parts[1];
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const status = tokens[i].replace(/^\n/, "");
if (!/^[A-Z]/.test(status)) continue;
const letter = status[0];
// A rename/copy is "R100\0old\0new" — the new path is the live one.
const file = letter === "R" || letter === "C" ? tokens[(i += 2)] : tokens[++i];
if (!file) continue;
// `-m` prints one diff per parent for a merge; --first-parent keeps it to
// one, but a defensive de-dup costs nothing and a doubled row is a lie.
if (seen.has(file)) continue;
@@ -282,6 +458,9 @@ function parseNameStatus(stdout, stats) {
/** One file's patch text, for any range. Empty string when there is none. */
async function patchFor(dir, { range, baseRef, sha, file, untracked, context }) {
// A secret file may be tracked by mistake; its hunks would carry the key —
// under its own name, or under a symlinked `.claude`'s target name.
if (await isSecretOnDisk(dir, file)) return "";
if (untracked) return synthesizeAddedPatch(dir, file);
const u = `-U${context}`;
const args =
@@ -290,10 +469,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
: range === "branch"
? ["diff", u, `${baseRef}...HEAD`, "--", file]
: ["diff", u, "HEAD", "--", file];
const result = await git(dir, [...args, "--no-color", "--no-ext-diff"]).catch(() => ({
stdout: "",
}));
return result.stdout;
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
}
/**
@@ -302,10 +478,10 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
* --no-index /dev/null <file>` would print, without the non-zero exit that
* wrapper treats as a failure.
*/
function synthesizeAddedPatch(dir, file) {
async function synthesizeAddedPatch(dir, file) {
let buf;
try {
buf = fs.readFileSync(path.join(dir, file));
buf = await fs.promises.readFile(await resolveInLane(dir, file));
} catch {
return "";
}
@@ -440,7 +616,9 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
const args = ["log", `-${n}`, `--format=${format}%x1e`];
if (file) args.push("--", file);
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file));
const result = await git(dir, args);
const commits = [];
for (const record of result.stdout.split("\x1e")) {
@@ -471,42 +649,60 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
* `rev` is `work` (the file on disk, including an untracked one), `HEAD`, or a
* commit-ish. The path is resolved against git's own index/tree rather than
* trusted: for `work` it is realpath-confined to the lane directory, because
* that is the one branch that reads the filesystem directly.
* that is the one branch that reads the filesystem directly. A SECRET_PATHS
* file is refused (ESECRET) at every rev.
*/
async function readFileAt(dir, file, rev = "work") {
if (typeof file !== "string" || !file || file.startsWith("/") || file.includes("\0")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
}
async function readFileAt(dir, requested, rev = "work") {
// Lexical guard FIRST and for every rev: `git show <rev>:<path>` reads the
// whole repo, so a `../` here is a committed file outside the lane.
const file = lanePath(requested);
if (rev !== "work" && !SAFE_REV.test(rev)) {
throw badRequest("EBADREV", "rev is not a valid revision");
}
// By resolved path too, at every rev: the key committed under a symlinked
// `.claude`'s target name is still the key.
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
let text;
let binary = false;
if (rev === "work") {
const abs = path.resolve(dir, file);
// `..` in the request must not walk out of the lane. Compare resolved
// paths, not the string the caller sent.
const root = path.resolve(dir);
if (abs !== root && !abs.startsWith(root + path.sep)) {
throw badRequest("EBADPATH", "path escapes the lane directory");
}
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
// compares REAL paths, not the string the caller sent.
const abs = await resolveInLane(dir, file);
let buf;
try {
buf = fs.readFileSync(abs);
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
// whole to answer with its first megabyte. The extra byte is what
// tells the cap below the file was longer.
const fh = await fs.promises.open(abs, "r");
try {
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
buf = Buffer.alloc(size);
let off = 0;
while (off < size) {
const { bytesRead } = await fh.read(buf, off, size - off, off);
if (!bytesRead) break;
off += bytesRead;
}
buf = buf.subarray(0, off);
} finally {
await fh.close();
}
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
binary = looksBinary(buf);
text = binary ? "" : buf.toString("utf8");
} else {
let out;
try {
out = await git(dir, ["show", `${rev}:${file}`]);
// `./`: resolve from the lane's cwd, not the repo root (a bare
// `<rev>:<path>` is root-relative) — `file` has no `..`, so it stays in.
text = await readCapped(dir, ["show", `${rev}:./${file}`], MAX_FILE_VIEW_BYTES);
} catch {
throw badRequest("ENOFILE", "no such file at that revision");
}
text = out.stdout;
binary = text.includes("\0");
if (binary) text = "";
}
@@ -530,12 +726,11 @@ async function readFileAt(dir, file, rev = "work") {
* skip-list has to be maintained here.
*/
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
const result = await git(dir, ["ls-files", "-z"]);
const { all, cut } = await lsFiles(dir, []);
const needle = String(q || "")
.toLowerCase()
.trim();
const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS);
const all = result.stdout.split("\0").filter(Boolean);
const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all;
// A basename hit beats a directory hit: typing "api" wants `api.ts`, not
// every file under `src/api/`.
@@ -546,7 +741,16 @@ async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
return ab - bb || a.length - b.length || a.localeCompare(b);
});
}
return { paths: hits.slice(0, cap), truncated: hits.length > cap, total: hits.length };
return { paths: hits.slice(0, cap), truncated: cut || hits.length > cap, total: hits.length };
}
/** `git ls-files -z [args]` → paths, and whether a huge listing was cut short. */
async function lsFiles(dir, args) {
const out = await readCapped(dir, ["ls-files", "-z", ...args], MAX_LS_CHARS);
const all = out.split("\0");
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
const cut = all.pop() !== "";
return { all: all.filter(Boolean), cut };
}
/**
@@ -560,13 +764,11 @@ async function listTree(dir, { subdir = "" } = {}) {
.replace(/^\/+|\/+$/g, "")
.trim();
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
const args = ["ls-files", "-z"];
if (clean) args.push("--", `${clean}/`);
const result = await git(dir, args);
const { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []);
const prefix = clean ? `${clean}/` : "";
const dirs = new Set();
const files = [];
for (const p of result.stdout.split("\0").filter(Boolean)) {
for (const p of all) {
const rest = p.slice(prefix.length);
const slash = rest.indexOf("/");
if (slash === -1) files.push({ name: rest, path: p, dir: false });
@@ -579,15 +781,88 @@ async function listTree(dir, { subdir = "" } = {}) {
return {
path: clean,
entries: entries.slice(0, MAX_PATHS),
truncated: entries.length > MAX_PATHS,
truncated: cut || entries.length > MAX_PATHS,
};
}
/**
* ONE hunk of a tracked file's working-copy diff (`git diff HEAD`, the same
* read the Changes panel shows), as a standalone patch `git apply` accepts:
* the file's own header lines plus that hunk. The hunk is named by its `@@`
* line and matched against a FRESH diff at the caller's context, so what gets
* applied is always git's own text of the current tree — never a patch a
* request carried. Null when no hunk matches (the tree moved on, or the file
* is a secret, binary or too large to read whole).
*/
async function hunkPatch(dir, file, header, context) {
if (!header || (await isSecretOnDisk(dir, file))) return null;
const u = `-U${normalizeContext(context)}`;
const raw = await readCapped(
dir,
["diff", u, "HEAD", "--no-color", "--no-ext-diff", "--", file],
MAX_FILE_BYTES
).catch(() => "");
// A capped read may end mid-hunk; applying half a hunk is worse than none.
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
const at = raw.indexOf("\n@@ ");
if (at < 0) return null;
const head = raw.slice(0, at + 1);
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
// line that does is a hunk boundary.
const hunk = raw
.slice(at + 1)
.split(/^(?=@@ )/m)
.find((h) => h.slice(0, h.indexOf("\n")) === header);
return hunk ? head + (hunk.endsWith("\n") ? hunk : `${hunk}\n`) : null;
}
/**
* Every level of the tracked tree from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
* per folder, which on a large repo is thousands of git processes.
*/
async function listTreeAll(dir) {
const { all, cut } = await lsFiles(dir, []);
const byDir = new Map();
const level = (d) => {
let l = byDir.get(d);
if (!l) byDir.set(d, (l = { dirs: new Set(), files: [] }));
return l;
};
level("");
for (const p of all) {
const parts = p.split("/");
for (let i = 0; i < parts.length - 1; i++) {
level(parts.slice(0, i).join("/")).dirs.add(parts[i]);
}
const parent = parts.slice(0, -1).join("/");
level(parent).files.push({ name: parts[parts.length - 1], path: p, dir: false });
}
const levels = {};
let truncated = cut;
for (const [d, { dirs, files }] of byDir) {
const prefix = d ? `${d}/` : "";
const entries = [
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
return { levels, truncated };
}
module.exports = {
collectChanges,
hunkPatch,
laneStatus,
listCommits,
listPaths,
listTree,
listTreeAll,
parseNumstat,
parsePorcelainZ,
parseUnifiedDiff,
readFileAt,
resolveBaseRef,
+125 -18
View File
@@ -9,7 +9,11 @@
*
* Four rules hold for every action:
* - argv only, via worktree.js's `git()` — never a shell string.
* - the lane's own working directory is the only cwd.
* - the lane's own working directory is the only cwd, and its boundary:
* in a lane that is a subdirectory of its repo, paths are cwd-relative
* and nothing outside the cwd can be named or swept into a commit.
* - every path is a LITERAL file name, never a pathspec glob — `new*.js`
* must not also discard new1.js (`literal: true` on every call).
* - a caller-supplied path is never trusted as a path: it must be one
* `git status` is reporting as changed right now (`resolvePaths`), so no
* `..`, no absolute path and no untouched file is reachable.
@@ -25,13 +29,19 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
git,
git: rawGit,
isValidBranchName,
listBranches,
branchCheckedOutAt,
statusCounts,
} = require("./worktree");
const { hunkPatch, laneStatus } = require("./git-diff");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
/** How long a fetch/pull/push may run before it is killed. */
const NETWORK_TIMEOUT_MS = 60_000;
@@ -47,6 +57,8 @@ const ACTIONS = new Set([
"stage",
"unstage",
"discard",
"stage-hunk",
"discard-hunk",
"branch-create",
"branch-delete",
]);
@@ -68,7 +80,9 @@ async function isDirty(dir) {
/**
* The paths a per-file action may name — exactly the ones `git status` is
* reporting as changed right now, keyed by their porcelain code.
* reporting as changed right now, keyed by path → `{code, from}` (`from` is a
* rename's original path). `-z`, so a path git would C-quote (non-ASCII, a
* space, a quote) matches the literal path the panel sends.
*
* This is the whole authorization story for `stage`/`unstage`/`discard`: the
* request cannot reach a path git is not already talking about, so no `..`,
@@ -76,18 +90,56 @@ async function isDirty(dir) {
* that is not in this map is a 400, never a silent no-op.
*/
async function changedPaths(dir) {
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
const map = new Map();
for (const line of result.stdout.split("\n")) {
if (!line.trim()) continue;
const code = line.slice(0, 2);
const raw = line.slice(3).trimEnd();
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
map.set(file, code);
}
for (const { code, path, from } of await laneStatus(dir)) map.set(path, { code, from });
return map;
}
/**
* The remote a push goes to — never a name from the request: the current
* branch's configured upstream remote, else the ONLY remote, else `origin`.
* Anything else is ambiguous, so ENOREMOTE rather than a guess.
*/
async function pushRemote(dir) {
const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean);
const branch = (
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
).stdout.trim();
if (branch) {
const upstream = (
await git(dir, ["config", "--get", `branch.${branch}.remote`]).catch(() => ({ stdout: "" }))
).stdout.trim();
if (remotes.includes(upstream)) return upstream;
}
if (remotes.length === 1) return remotes[0];
if (remotes.includes("origin")) return "origin";
throw bad(
"ENOREMOTE",
remotes.length
? "several remotes and no upstream or origin: set this branch's upstream first"
: "this repository has no remote to push to"
);
}
/** Throw ESTAGEDOUTSIDE when the index holds a change outside the lane's cwd. */
async function assertNothingStagedOutside(dir) {
const prefix = (await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim();
if (!prefix) return; // the lane IS the repo root: nothing is outside it
// No --relative: root-relative names, so outside entries are visible.
// --no-renames: a staged move INTO the lane is listed as its new path only
// otherwise, and the deletion of the old path outside would slip through.
const staged = (await git(dir, ["diff", "--cached", "--name-only", "--no-renames", "-z"])).stdout
.split("\0")
.filter(Boolean);
const outside = staged.find((p) => !p.startsWith(prefix));
if (outside) {
throw bad(
"ESTAGEDOUTSIDE",
`refusing to commit: ${outside} is staged outside this lane's directory`
);
}
}
/** Validate `body.paths` against what git reports as changed. */
async function resolvePaths(dir, body) {
const wanted = Array.isArray(body.paths)
@@ -104,7 +156,7 @@ async function resolvePaths(dir, body) {
if (unknown.length) {
throw bad("EBADPATH", `not a changed path: ${unknown[0]}`);
}
return wanted.map((p) => ({ path: p, code: changed.get(p) }));
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
}
/**
@@ -138,9 +190,10 @@ async function runGitAction(dir, action, body = {}) {
// HEAD, not a branch name from the request: the lane pushes the branch it
// is actually on, and -u makes the first push set the upstream the
// ahead/behind counts then read.
const remote = await pushRemote(dir);
return done(
action,
await git(dir, ["push", "-u", "origin", "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
await git(dir, ["push", "-u", remote, "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
);
}
@@ -159,7 +212,14 @@ async function runGitAction(dir, action, body = {}) {
const args =
untracked.length === paths.length && !(await hasHead(dir))
? ["rm", "--cached", "-r", "--", ...paths.map((p) => p.path)]
: ["restore", "--staged", "--", ...paths.map((p) => p.path)];
: // A staged rename's ORIGINAL path rides along, as in `discard`:
// restoring only the new one leaves the old path's deletion staged.
[
"restore",
"--staged",
"--",
...paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
];
return done(action, await git(dir, args));
}
@@ -167,7 +227,11 @@ async function runGitAction(dir, action, body = {}) {
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
const paths = await resolvePaths(dir, body);
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
const tracked = paths.filter((p) => p.code !== "??").map((p) => p.path);
// A rename's ORIGINAL path rides along: restoring only the new one would
// drop it from the index and the tree, and leave the old one deleted.
const tracked = paths
.filter((p) => p.code !== "??")
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
const out = [];
// Untracked files are deleted (git has no copy to restore); tracked ones
// go back to HEAD in both gutters, so a staged-then-edited file lands in
@@ -181,20 +245,63 @@ async function runGitAction(dir, action, body = {}) {
return done(action, mergeResults(out));
}
if (action === "stage-hunk" || action === "discard-hunk") {
// Undoing a hunk is as irreversible as discarding a file: same gate.
if (action === "discard-hunk" && body.confirm !== true) {
throw bad("ECONFIRM", "discard-hunk requires confirm: true");
}
const [target] = await resolvePaths(dir, { path: body.path });
if (target.code === "??") {
throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file");
}
const patch = await hunkPatch(
dir,
target.path,
typeof body.hunk === "string" ? body.hunk : "",
body.context
);
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
// The patch names paths from the repo root, so it is applied there.
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-hunk-"));
const file = path.join(tmp, "hunk.patch");
try {
await fs.promises.writeFile(file, patch);
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
// reversed, off the working tree. A hunk the index or tree no longer
// matches fails in git's own words (409), never half-applies.
const args =
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
return done(action, await git(top, [...args, file]));
} finally {
await fs.promises.rm(tmp, { recursive: true, force: true });
}
}
if (action === "commit") {
const message = typeof body.message === "string" ? body.message.trim() : "";
if (!message) throw bad("EBADMESSAGE", "commit message is required");
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
// `git commit` takes the WHOLE index, whatever the cwd, and a pathspec
// would switch it to committing the working-tree copy (unstaged edits
// included) — so a subdirectory lane refuses rather than sweep another
// actor's staged work outside it into this lane's commit.
await assertNothingStagedOutside(dir);
if (body.only === "staged") {
// The panel's per-file staging only means something if a commit can
// take the index as it stands — no `add -A` sweeping in the rest.
const staged = [...(await changedPaths(dir)).values()].some(
(code) => code[0] !== " " && code[0] !== "?"
({ code }) => code[0] !== " " && code[0] !== "?"
);
if (!staged) throw bad("ENOCHANGES", "nothing staged to commit");
} else {
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to commit");
await git(dir, ["add", "-A"]);
// The LANE's status, not the repo's: `add -A -- .` below only takes the
// lane's files, so a clean subdirectory lane in a repo dirty elsewhere
// has nothing to commit — not a git failure after the fact.
if (!(await laneStatus(dir)).length) throw bad("ENOCHANGES", "nothing to commit");
// `-- .`: the lane's own directory, not the whole repo it sits in.
await git(dir, ["add", "-A", "--", "."]);
}
// -m takes the message as one argv element, so a message containing
// newlines, quotes or a leading dash is content, never syntax.
+12
View File
@@ -128,6 +128,18 @@ function missingSkillPlugins() {
* @returns {{ok: boolean, output: string}}
*/
function runClaudePlugin(args, timeoutMs) {
const result = runClaudePluginOnce(args, timeoutMs);
// An older Claude Code rejects `-y` and never prompts; retry without it.
if (!result.ok && args.includes("-y") && /unknown option '-y'/.test(result.output)) {
return runClaudePluginOnce(
args.filter((a) => a !== "-y"),
timeoutMs
);
}
return result;
}
function runClaudePluginOnce(args, timeoutMs) {
const env = { ...process.env };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
const result = spawnSync("claude", args, {
+55 -3
View File
@@ -30,7 +30,7 @@ const STATUS_FILE_CAP = 20;
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(cwd, args, { timeout = 0 } = {}) {
async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false } = {}) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
@@ -52,12 +52,23 @@ async function git(cwd, args, { timeout = 0 } = {}) {
}
// Prevent credential prompts from hanging a background provisioning job
env.GIT_TERMINAL_PROMPT = "0";
// Pathspec behaviour is ours to choose, never the parent's. `literal` makes a
// path argument name exactly one file: without it `new*.js` or `app/[id]/`
// is a GLOB, and `git clean -- 'new*.js'` deletes every match.
delete env.GIT_GLOB_PATHSPECS;
delete env.GIT_NOGLOB_PATHSPECS;
delete env.GIT_ICASE_PATHSPECS;
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
else delete env.GIT_LITERAL_PATHSPECS;
try {
const result = await execFileAsync("git", args, {
cwd,
env,
maxBuffer: 8 * 1024 * 1024,
// A read with its own size cap passes a bigger buffer and treats an
// overflow (err.code ERR_CHILD_PROCESS_STDIO_MAXBUFFER, partial output in
// err.git.stdout) as truncated rather than as a failure.
maxBuffer,
// 0 = no limit, node's own default. A network command (fetch/pull/push)
// passes one so an unreachable remote fails instead of pinning a request
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
@@ -631,6 +642,46 @@ async function statusCounts(dir) {
return { dirty, untracked, head, files };
}
/**
* The lane card's view of the working copy: only entries inside the lane's own
* cwd, cwd-relative and read with `-z` — the same scoping the Changes panel
* uses (`laneStatus`), so a subdirectory lane's card never counts or lists a
* sibling's files, and a non-ASCII name is not shown C-quoted. `statusCounts`
* stays whole-repo on purpose: `switch` and the preflight need to know about
* ANY dirt a branch change would carry across.
*/
async function laneFiles(dir) {
// Lazy: git-diff.js requires this module, so a top-level require would cycle.
const { laneStatus, parseNumstat } = require("./git-diff");
const entries = await laneStatus(dir);
let stats = new Map();
try {
// -z --relative: cwd-scoped, cwd-relative, unquoted — keys the entries.
stats = parseNumstat(
(await git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"])).stdout
);
} catch {
/* no HEAD to diff against -> no per-file deltas */
}
let dirty = 0;
let untracked = 0;
const files = [];
for (const e of entries) {
if (e.code === "??") untracked++;
else dirty++;
if (files.length < STATUS_FILE_CAP) {
files.push({
path: e.path,
status: e.code,
// Counts only: the card's file record has no `binary` field.
added: stats.get(e.path)?.added ?? null,
deleted: stats.get(e.path)?.deleted ?? null,
});
}
}
return { dirty, untracked, files };
}
/**
* What a lane's working copy looks like right now: which branch it is on, the
* short HEAD, that commit's subject, and how much is uncommitted.
@@ -641,7 +692,8 @@ async function statusCounts(dir) {
* caller shows what git says rather than inventing a nicer word for it.
*/
async function gitFacts(dir) {
const { dirty, untracked, head, files } = await statusCounts(dir);
const { head } = await statusCounts(dir);
const { dirty, untracked, files } = await laneFiles(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
+53 -7
View File
@@ -60,6 +60,11 @@ const schemas = {
"Title for the lane if one has to be created; ignored when one already exists.",
example: "App package",
},
exact: {
type: "boolean",
description:
"Only a lane on this very directory counts; a parent directory's lane is not returned, so a nested directory gets its own lane.",
},
},
},
LaneDestructiveActionRequest: {
@@ -111,7 +116,7 @@ const paths = {
tags: ["Lanes"],
summary: "Find or adopt the lane owning a working directory",
description:
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
operationId: "ensureLane",
requestBody: {
required: true,
@@ -506,7 +511,7 @@ const paths = {
tags: ["Lanes"],
summary: "The lane's recent commits",
description:
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
operationId: "listLaneCommits",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -515,6 +520,7 @@ const paths = {
],
responses: {
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
400: { description: "EBADPATH (file= is absolute or has a `..` segment)." },
404: { description: "Lane not found." },
},
},
@@ -533,7 +539,13 @@ const paths = {
],
responses: {
200: { description: "{available, path, rev, binary, truncated, lines}." },
400: { description: "EBADPATH, EBADREV or ENOFILE." },
400: {
description: "EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.",
},
403: {
description:
"ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
},
404: { description: "Lane not found." },
},
},
@@ -573,6 +585,20 @@ const paths = {
},
},
},
"/api/lanes/{id}/git/tree-all": {
get: {
tags: ["Lanes"],
summary: "Every level of the tracked tree in one read",
description:
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.",
operationId: "listLaneTreeAll",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "{available, levels:{<dir>:[{name, path, dir}]}, truncated}." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/branches": {
get: {
tags: ["Lanes"],
@@ -592,7 +618,7 @@ const paths = {
tags: ["Lanes"],
summary: "One git write on the lane's working copy",
description:
'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent\'s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.',
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
operationId: "runLaneGitAction",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -609,6 +635,8 @@ const paths = {
"stage",
"unstage",
"discard",
"stage-hunk",
"discard-hunk",
"commit",
"switch",
"branch-create",
@@ -641,9 +669,23 @@ const paths = {
items: { type: "string" },
description: "stage, unstage, discard; each must be a path git reports changed.",
},
path: {
type: "string",
description:
"stage-hunk, discard-hunk; a path git reports changed (not untracked).",
},
hunk: {
type: "string",
description:
"stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.",
},
context: {
type: "integer",
description: "stage-hunk, discard-hunk; the context the hunk was read at.",
},
confirm: {
type: "boolean",
description: "discard only; the call is refused without it.",
description: "discard and discard-hunk; the call is refused without it.",
},
},
},
@@ -654,10 +696,14 @@ const paths = {
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
400: {
description:
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.",
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.",
},
404: { description: "Lane not found." },
500: { description: "git failed; its stderr is appended to error.message." },
409: {
description:
"EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.",
},
500: { description: "Unexpected server-side failure." },
},
},
},
+1
View File
@@ -139,6 +139,7 @@ const SETTINGS_INFO_EXAMPLE = {
heapTotal: 71303168,
heapUsed: 58392104,
external: 3211884,
heapLimit: 4345298944,
arrayBuffers: 1048576,
},
cpu_load: [2.13, 2.45, 2.31],
+81 -11
View File
@@ -31,6 +31,7 @@ const { preflight } = require("../lib/lane-preflight");
const {
LANES_ROOT,
addWorktree,
git,
gitFacts,
isGitRepo,
isValidBranchName,
@@ -40,7 +41,14 @@ const {
resolveBase,
slugify,
} = require("../lib/worktree");
const { collectChanges, listCommits, listPaths, listTree, readFileAt } = require("../lib/git-diff");
const {
collectChanges,
listCommits,
listPaths,
listTree,
listTreeAll,
readFileAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
@@ -293,7 +301,9 @@ router.get("/branches", async (req, res) => {
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
res.json({ branches, current, worktrees });
} catch (err) {
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
res
.status(500)
.json({ error: { code: gitErrorCode(err), message: err.message, git: err.git } });
}
});
@@ -318,7 +328,15 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
// already own this (cwd, tool) pair" is the real question, so a request for
// a different tool than an existing lane at this cwd falls through to
// creating that tool's own lane instead of adopting the wrong one.
const owner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
//
// `exact: true` is the Add-lane modal's question — "is THIS directory a
// lane" — so a lane on a PARENT directory does not answer it (#42: the
// modal closed on the parent lane and no new card ever appeared). Without
// it, a nested cwd still resolves to its owner, which is what the CLI and
// other callers adopting "the lane I am inside" rely on.
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
const strip = (p) => p.replace(/\/+$/, "");
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane
// is how callers like Sagi adopt the lane, so don't clobber a real title
@@ -800,7 +818,10 @@ router.get("/:id/git/log", async (req, res) => {
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
});
res.json({ available: true, commits });
} catch {
} catch (err) {
if (err.code === "EBADPATH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false, commits: [] });
}
});
@@ -809,7 +830,8 @@ router.get("/:id/git/log", async (req, res) => {
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
* viewer's "show the entire file" mode. Path validation lives in
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
* touches the filesystem, and it is realpath-confined to the lane.
* touches the filesystem, and it is realpath-confined to the lane (symlinks
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
*/
router.get("/:id/git/file", async (req, res) => {
const lane = laneOr404(req, res);
@@ -822,6 +844,10 @@ router.get("/:id/git/file", async (req, res) => {
);
res.json({ available: true, ...file });
} catch (err) {
// A secret file (the router's API key) is refused at every rev.
if (err.code === "ESECRET") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
@@ -840,6 +866,17 @@ router.get("/:id/git/ls", async (req, res) => {
}
});
/** Every level of the tracked tree in one read, for "expand all folders". */
router.get("/:id/git/tree-all", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
res.json({ available: true, ...(await listTreeAll(lane.cwd)) });
} catch {
res.json({ available: false, levels: {}, truncated: false });
}
});
/** One directory level of tracked files, for the file explorer. */
router.get("/:id/git/tree", async (req, res) => {
const lane = laneOr404(req, res);
@@ -876,8 +913,10 @@ router.get("/:id/git/branches", async (req, res) => {
/**
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
* discard, commit, switch, branch-create and branch-delete.
* Guarded (they mutate), serialized per lane (`withLaneLock`) so a push and a
* commit fired from two tabs cannot interleave on one index, and thin — every
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
* the worktree's git dir, so a claude and a codex lane on one directory share
* it) so a push and a commit fired from two tabs or two lanes cannot
* interleave on one index, and thin — every
* rule about what each action may do lives in server/lib/git-ops.js, not here.
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
@@ -885,7 +924,15 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const result = await withLaneLock(lane.id, () =>
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
// cwd — or a root lane and a subdirectory lane of one repo — share one
// index, and their writes would otherwise race on index.lock. The git dir
// is per worktree, so sibling worktree lanes still run in parallel; a
// cwd that is not a repo falls back to its real path.
const gitDir = await git(lane.cwd, ["rev-parse", "--absolute-git-dir"])
.then((r) => r.stdout.trim())
.catch(() => fs.promises.realpath(lane.cwd).catch(() => lane.cwd));
const result = await withLaneLock(`git:${gitDir}`, () =>
runGitAction(lane.cwd, req.params.action, req.body || {})
);
res.json(result);
@@ -902,8 +949,21 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EBADPATH",
"ECONFIRM",
"EPROTECTED",
"ESTAGEDOUTSIDE",
"ENOREMOTE",
"EUNTRACKED",
];
const status = badRequest.includes(err.code) ? 400 : 500;
// A numeric code is git's own exit status (execFile reports one that way):
// git ran and refused — a rejected push, a failed credential, a diverged
// pull. That is the repository's state answering a valid request, not a
// server fault, so it is a 409 under the string code the client expects.
const exited = typeof err.code === "number";
// EHUNKGONE is the same kind of answer: the tree moved under the click.
const status = badRequest.includes(err.code)
? 400
: exited || err.code === "EHUNKGONE"
? 409
: 500;
// git's own stderr is the useful half of a failed push or pull (a
// rejection, a missing credential, a diverged branch), so it joins the
// message rather than riding a side field the client would have to know
@@ -911,7 +971,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const detail = err.git ? (err.git.stderr || "").trim() : "";
res.status(status).json({
error: {
code: err.code || "EGIT",
code: gitErrorCode(err),
message: detail ? `${err.message}\n${detail}` : err.message,
},
});
@@ -1146,7 +1206,7 @@ function sendLifecycleError(res, err) {
}
if (err.git) {
return res.status(500).json({
error: { code: err.code, message: err.message, stderr: err.git.stderr },
error: { code: gitErrorCode(err), message: err.message, stderr: err.git.stderr },
});
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
@@ -1199,6 +1259,16 @@ function sendRuntimeError(res, err) {
});
}
/**
* The string `error.code` for an error that may have come out of `git()`.
* When git exits non-zero, execFile sets `err.code` to the exit STATUS (a
* number, e.g. 128); the client branches on string codes, so that becomes
* `EGIT`. A missing code (a timeout kill) is `EGIT` too; string codes pass.
*/
function gitErrorCode(err) {
return typeof err.code === "string" && err.code ? err.code : "EGIT";
}
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);
+5 -1
View File
@@ -7,6 +7,7 @@ const { Router } = require("express");
const fs = require("fs");
const path = require("path");
const os = require("os");
const v8 = require("v8");
const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db");
const { getConnectionCount } = require("../websocket");
const { transcriptCache } = require("./hooks");
@@ -121,7 +122,10 @@ router.get("/info", (req, res) => {
node_version: process.version,
platform: process.platform,
ws_connections: getConnectionCount(),
memory: process.memoryUsage(),
// heapTotal is only what V8 has grown the heap to SO FAR, so used/total
// sits at 70-90% on a perfectly healthy process. heapLimit is the ceiling
// the process actually dies at — the denominator a "heap used %" needs.
memory: { ...process.memoryUsage(), heapLimit: v8.getHeapStatistics().heap_size_limit },
cpu_load: os.loadavg(),
arch: os.arch(),
total_mem: os.totalmem(),