Compare commits

...

25 Commits

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

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

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

Closes #42
2026-09-24 09:44:42 +07:00
nntrivi2001 2ea2184372 chore(release): bump version to 1.8.3 2026-09-23 16:46:40 +07:00
nntrivi2001 aff863c69f fix(plugins): retry claude plugin install|update without -y on an old CLI
An older Claude Code has no -y on `plugin install|update` and exits with
"error: unknown option '-y'", so the dashboard's Update now chain failed at
its plugin_update step (and so did Update all, the plugin buttons and the
superpowers auto-install). Those versions also predate the confirmation
prompt -y answers, so every wrapper now retries the same argv without it
when, and only when, the CLI rejects the flag. /agenthub-update, which the
failure message points users to, falls back the same way.
2026-09-23 16:46:20 +07:00
nntrivi2001 764b967856 fix(dashboard): measure V8 heap against its limit, not its current size
The Health Score's heap component divided heapUsed by heapTotal, which is
only what V8 has grown the heap to so far, so a healthy server read 70-90%
and dragged the score down. GET /api/settings/info now also reports
server.memory.heapLimit (v8 heap_size_limit, the ceiling the process dies
at); the V8 Heap bar and the Health Score divide by it, falling back to
heapTotal against an older server.
2026-09-23 16:46:00 +07:00
nntrivi2001 94ee2dc79c chore(release): bump version to 1.8.2 2026-09-23 13:23:40 +07:00
nntrivi2001 3f50e162e6 feat(run): Enter sends the prompt, and a busy lane attaches instead of failing
- The prompt box sends on Enter (Ctrl/Cmd+Enter still works); Shift+Enter
  inserts a newline, and IME composition (Vietnamese/CJK) never sends.
- Starting or resuming into a lane that still holds a live run answered a
  raw 409 ERUNLIVE; the console now attaches to that run with a notice.
  Checked by error code, since the server's message is prose.
- Active runs gains a two-click Kill for live dashboard-spawned rows, the
  way to free a lane when the previous session really should go.
2026-09-23 13:23:20 +07:00
nntrivi2001 1f5c04e765 feat(ui): warm light theme and pre-mixed status fills
- Light mode moves to Claude's ivory/terracotta family: page #e8e5da, cards
  #f5f3ec (a notch below Claude's ivory, no pure-white step), warm
  borders and card shadow; dark mode is untouched.
- The surface ramp rises in lightness with its index in BOTH themes, so a
  card sits brighter than the page and every hover lifts instead of dims.
- Light --accent #a34a28 (the brand #cc785c is 3.1:1 on ivory); --fg-muted
  held at >=4.5:1; new --syn-* syntax tokens, dark keeps its pastels.
- Status/accent fills (bg-*/N, border-*/N) compile to color-mix against
  --tint-base instead of rgb(/a), so a chip is one colour on every surface.
- Hardcoded dark-only hovers (hover:text-white, hover:bg-white/5) replaced
  by tokens in DateTimePicker and SessionDrillIn.
2026-09-23 13:23:00 +07:00
nntrivi2001 952ee7c724 chore(release): bump version to 1.8.1 2026-09-23 09:18:17 +07:00
nntrivi2001 92cee94431 fix(hooks): store a hook delivered twice only once
A doubled hook registration (plugin + leftover checkout install) POSTs every
event twice with a byte-identical payload, so the timeline showed each row
twice and token/alert/lane side effects ran twice. POST /api/hooks/event now
skips a payload identical to the session's LATEST event from the last 10s
(findRecentDuplicateEvent). Latest-only keeps two legitimately identical
Stop events of consecutive turns, which always have other events between.
2026-09-23 09:18:17 +07:00
nntrivi2001 1b561c22d9 feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
2026-09-23 09:18:17 +07:00
127 changed files with 16416 additions and 2184 deletions
+9 -4
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -42,6 +42,13 @@ Internal build — all rights reserved.
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Git, from the dashboard.** Each lane card shows its branch, `↑ahead ↓behind`
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
Beside the lane sits a collapsible **Changes** panel — an overview of the
changed files, a full review of every diff, or one file at a time, comparing
either uncommitted work or the whole branch against its base. There is no
merge/rebase/reset button by design: recovery from those needs a terminal.
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per lane (`agenthub lanes up|down|logs`).
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
@@ -57,6 +64,10 @@ Internal build — all rights reserved.
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark; light:
Default, Apple Light, IntelliJ Light, Canvas. Each mode remembers its own
theme, so the sidebar's quick toggle swaps between the two. Per-browser only.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
@@ -278,7 +289,9 @@ lane. This is resource namespacing on the host, not a container: lanes run as th
same user and share the network.
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
`POST /:id/git/:action`), and the full
runtime/profile contract.
## Tests
+31 -2
View File
@@ -52,7 +52,17 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
- **React Router 6.28** - Client-side routing with nested layouts
- **WebSocket** - Real-time event streaming from server
- **Lucide Icons** - Modern, consistent icon set
@@ -198,7 +208,7 @@ client/
│ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
│ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── App.tsx # Root component + router setup
@@ -726,6 +736,25 @@ flowchart LR
Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button.
#### Tooltips (`components/Tooltip.tsx`)
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
app the same designed tooltip — there is no wrapper to add at the call site.
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
focus (immediately) it takes the element's native `title`, shows it as an
anchored bubble with an arrow, and puts the attribute back when the bubble
closes. Consequences worth knowing:
- **Write `title="…"` exactly as before.** That is the whole API. A control
keeps its native tooltip, and its accessible name, when JS has not run.
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
in light mode; it flips above/below, clamps to the viewport, and closes on
scroll, resize, pointer-down or `Escape`.
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
expanding a truncated **number** inside a line of text, but draws the same
surface so the two read as one thing.
---
## Utilities
+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" />
+4
View File
@@ -60,6 +60,7 @@ import { useCallback } from "react";
import { Layout } from "./components/Layout";
import { DocumentTitle } from "./components/DocumentTitle";
import { SplashScreen } from "./components/SplashScreen";
import { GlobalTooltip } from "./components/Tooltip";
import { Dashboard } from "./pages/Dashboard";
import { History } from "./pages/History";
import { SessionDetail } from "./pages/SessionDetail";
@@ -87,6 +88,9 @@ export default function App() {
return (
<>
<SplashScreen />
{/* One renderer gives every `title` in the app the designed bubble —
see components/Tooltip.tsx for why it is not a wrapper component. */}
<GlobalTooltip />
<BrowserRouter>
<DocumentTitle />
<Routes>
+3 -1
View File
@@ -182,7 +182,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<div
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
isMain
? "bg-accent/15 text-accent"
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
}`}
>
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
+7 -4
View File
@@ -174,7 +174,7 @@ export function DateTimePicker({
? "bg-accent text-white font-medium"
: isToday
? "bg-surface-3 text-accent font-medium"
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`}
>
{i}
@@ -201,7 +201,10 @@ export function DateTimePicker({
{dateObj ? formatDisplay(dateObj) : placeholder}
</span>
{dateObj && (
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
<X
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
onClick={clearValue}
/>
)}
</button>
@@ -216,7 +219,7 @@ export function DateTimePicker({
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronLeft className="w-4 h-4" />
</button>
@@ -228,7 +231,7 @@ export function DateTimePicker({
onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
}
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
>
<ChevronRight className="w-4 h-4" />
</button>
+1 -1
View File
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
const color = value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
return (
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
+4 -4
View File
@@ -625,7 +625,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-400"
color="text-blue-700 dark:text-blue-400"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
@@ -672,12 +672,12 @@ export function ImportHistory() {
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events}
color="text-violet-300"
color="text-violet-700 dark:text-violet-300"
/>
<ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
value={backupResult.model_pricing}
color="text-cyan-300"
color="text-cyan-700 dark:text-cyan-300"
/>
</div>
<p className="text-[11px] text-fg-muted">
@@ -737,7 +737,7 @@ function ModeButton({
>
<div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-400" : "text-fg-secondary"
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
}`}
>
{icon}
+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" />
+9 -5
View File
@@ -4,6 +4,13 @@
* by {@link StatCard} for raw metric values and anywhere a compact display needs
* a full-precision expansion without cluttering the layout.
*
* ## Look
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
* are visibly one thing. It stays cursor-following (and not anchored like the
* global one) because it expands a NUMBER inside a line of text, where the
* anchor is a few characters wide and the cursor is the real subject.
*
* ## Portal rendering
* Tooltip content is portaled to `document.body` with `position: fixed` so
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
@@ -56,6 +63,7 @@
import { useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { TOOLTIP_SURFACE } from "./Tooltip";
/** Props for {@link Tip}. */
interface TipProps {
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
{children}
{show &&
createPortal(
<div
ref={tipRef}
style={tipStyle}
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
>
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
{raw}
</div>,
document.body
+280
View File
@@ -0,0 +1,280 @@
/**
* @file Tooltip.tsx
* @description The dashboard's tooltip look, and the one renderer that gives it
* to every control in the app.
*
* ## Why a global renderer instead of a wrapper component
* The UI already labels its icon-only controls with the native `title`
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
* touching every one of those call sites to get a designed bubble, and the
* next `title=` someone writes would be back to the browser's grey box.
* {@link GlobalTooltip} instead listens once at the document: on hover or
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
* by removing the attribute for as long as the bubble is up, and restores it
* on the way out. Nothing else in the app has to know it exists, and a control
* keeps its native tooltip (and its accessible name) when JS has not run.
* While the title is parked, a control with no other name (icon-only) carries
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
* bubble goes away with its control if that is removed from the page.
*
* ## The look
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
* hard to read and lands over the thing it describes. It flips above/below and
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
* after a short delay so a cursor crossing a toolbar does not strobe, and is
* built from the theme's own tokens so it is legible in light mode too (the
* old bubble was a hardcoded near-black).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
export const TOOLTIP_SURFACE =
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
"whitespace-pre-wrap break-words";
/** How long the pointer must rest on a control before its tooltip appears. */
const SHOW_DELAY_MS = 350;
/** Gap between the control and the bubble, and the minimum viewport margin. */
const GAP = 8;
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
* `[data-tip]` matches the control whose title is parked, so moving between
* its own children still resolves to it instead of re-opening the bubble. */
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
type Anchored = { text: string; rect: DOMRect };
type Placed = { left: number; top: number; arrow: number; above: boolean };
/**
* Mount ONCE, near the router root. Every `title` in the app becomes a
* designed tooltip; nothing else has to change.
*/
export function GlobalTooltip() {
const [tip, setTip] = useState<Anchored | null>(null);
const [pos, setPos] = useState<Placed | null>(null);
const bubble = useRef<HTMLDivElement>(null);
// The element whose `title` is currently parked in `data-tip`, so it can be
// put back even if the pointer never leaves it (a route change, a re-render
// that unmounts it, Escape).
const parked = useRef<HTMLElement | null>(null);
const timer = useRef<number | undefined>(undefined);
// The element whose delay is running, so re-entering it does not restart it.
const pending = useRef<HTMLElement | null>(null);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
pending.current = null;
const el = parked.current;
if (el) {
// Drop the instance shim `open` installed; the prototype method is back.
delete (el as { removeAttribute?: unknown }).removeAttribute;
const text = el.dataset.tip;
if (text !== undefined) {
// React re-set `title` while it was parked (a poll flipped the hint):
// that value is current, the parked copy is stale. React never
// re-applies an unchanged prop, so writing the old one back would win
// for good.
if (!el.hasAttribute("title")) el.setAttribute("title", text);
delete el.dataset.tip;
}
if (el.dataset.tipAria !== undefined) {
el.removeAttribute("aria-label");
delete el.dataset.tipAria;
}
parked.current = null;
}
setTip(null);
setPos(null);
}, []);
const open = useCallback(
(el: HTMLElement, delay: number) => {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
pending.current = el;
timer.current = window.setTimeout(() => {
pending.current = null;
// Re-read at fire time: the control may have been re-rendered, removed
// or had its title changed while the delay was running.
if (!el.isConnected) return;
const current = (el.getAttribute("title") || "").trim();
if (!current) return;
el.dataset.tip = current;
el.removeAttribute("title");
// React removing `title` while it is parked calls removeAttribute on an
// attribute that is already gone: no MutationRecord, nothing to see —
// and `restore` would write the stale text back for good. So, on THIS
// element and only while parked, that call is taken as React's intent:
// the parked copy is dropped and the bubble hides. (React DOM calls
// `node.removeAttribute`, the instance method; `restore` deletes the
// shim.) No global prototype is touched.
el.removeAttribute = (name: string) => {
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
delete el.dataset.tip;
restore();
}
Element.prototype.removeAttribute.call(el, name);
};
// The title was an icon-only control's ONLY name; keep it named.
if (
!el.hasAttribute("aria-label") &&
!el.hasAttribute("aria-labelledby") &&
!el.textContent?.trim()
) {
el.setAttribute("aria-label", current);
el.dataset.tipAria = "";
}
parked.current = el;
setTip({ text: current, rect: el.getBoundingClientRect() });
}, delay);
},
[restore]
);
useEffect(() => {
const target = (e: Event) => {
const node = e.target;
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
};
const onOver = (e: PointerEvent) => {
const el = target(e);
// Already showing or already counting down for this control: a pointer
// crossing its children must neither hide nor restart it.
if (!el || el === parked.current || el === pending.current) return;
open(el, SHOW_DELAY_MS);
};
const onOut = (e: PointerEvent) => {
// Moving between children of the same control must not flicker.
const el = target(e);
const to = e.relatedTarget;
if (el && to instanceof Node && el.contains(to)) return;
if (el || parked.current) restore();
};
// Keyboard users get the same tooltip, immediately — a focus ring that
// reveals nothing is the whole reason icon-only buttons are hard to learn.
// Only KEYBOARD focus, though: a click focuses too, and without the
// `:focus-visible` check it reopened the bubble the pointerdown just
// closed, with no delay. Hover keeps its own path.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (!el || el === parked.current) return;
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
open(el, 0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") restore();
};
document.addEventListener("pointerover", onOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("pointerdown", restore);
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", restore);
document.addEventListener("keydown", onKey);
// A bubble anchored to a rect is wrong the moment its anchor moves — but
// only a scroll of the document or of a box containing the anchor moves
// it. A log tailing in another panel must not close the bubble.
const onScroll = (e: Event) => {
const anchor = parked.current || pending.current;
const t = e.target;
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", restore);
window.addEventListener("blur", restore);
return () => {
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("pointerdown", restore);
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", restore);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", restore);
window.removeEventListener("blur", restore);
restore();
};
}, [open, restore]);
// A control unmounted under a still cursor fires no pointerout: without this
// its bubble stays up over whatever replaced it. The same goes for a title
// re-rendered under it — the bubble would keep saying the old text, so it
// hides and `restore` keeps the new attribute.
useEffect(() => {
if (!tip) return;
const watch = new MutationObserver((records) => {
const el = parked.current;
if (!el) return;
if (
!el.isConnected ||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
)
restore();
});
watch.observe(document.body, { childList: true, subtree: true });
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
return () => watch.disconnect();
}, [tip, restore]);
// Placed after the first paint, when the bubble's real size is known —
// measuring it is the only way to flip and clamp without guessing.
useEffect(() => {
if (!tip || !bubble.current) return;
const { width, height } = bubble.current.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const centre = tip.rect.left + tip.rect.width / 2;
let left = centre - width / 2;
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
// Below by default; above only when below would run off the screen AND
// above actually fits, so a control at the very bottom still gets a bubble.
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
setPos({
left,
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
// The arrow points at the control even after the bubble was clamped.
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
above,
});
}, [tip]);
if (!tip) return null;
return createPortal(
<div
ref={bubble}
role="tooltip"
data-testid="global-tooltip"
style={{
position: "fixed",
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
opacity: pos ? 1 : 0,
transition: "opacity 120ms ease-out",
}}
className={TOOLTIP_SURFACE}
>
{tip.text}
{/* Two borders only: a fully bordered diamond draws its other two
edges straight across the bubble it is attached to. */}
<span
aria-hidden
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
pos?.above ? "border-b border-r" : "border-l border-t"
} border-border`}
/>
</div>,
document.body
);
}
export default GlobalTooltip;
@@ -0,0 +1,59 @@
/**
* @file Sidebar.theme.test.tsx
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
function renderSidebar(collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
</MemoryRouter>
);
}
describe("Sidebar theme controls", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
renderSidebar();
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking System sets the mode and presses only System", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "System" }));
expect(localStorage.getItem("theme")).toBe("system");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking Light applies light mode to <html>", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "Light" }));
expect(localStorage.getItem("theme")).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
const user = userEvent.setup();
localStorage.setItem("theme", "light");
renderSidebar(true);
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
});
});
@@ -0,0 +1,288 @@
/**
* @file Tooltip.test.tsx
* @description Pins the app-wide tooltip renderer: a control's native `title`
* becomes the designed bubble on hover and on keyboard focus, the native
* tooltip is suppressed only while the bubble is up and the attribute is put
* back afterwards (it is the control's accessible name), and `data-tip-off`
* opts out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
return render(
<>
<GlobalTooltip />
<button title="Refresh the working copy">refresh</button>
{extra}
</>
);
}
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
// jsdom's :focus-visible is a heuristic over the previous events and leaks
// between tests, so the tests say which kind of focus they mean: a spy answers
// `:focus-visible` from this flag and passes every other selector through.
let keyboardFocus = false;
const realMatches = Element.prototype.matches;
beforeEach(() => {
keyboardFocus = false;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
return sel === ":focus-visible"
? keyboardFocus && document.activeElement === this
: realMatches.call(this, sel);
});
});
afterEach(() => vi.restoreAllMocks());
const tabTo = (el: HTMLElement) => {
keyboardFocus = true;
act(() => el.focus());
};
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
const settle = async () => {
await act(async () => {
vi.advanceTimersByTime(400);
});
};
describe("GlobalTooltip", () => {
it("shows the bubble after a delay and takes the native title while it is up", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
expect(screen.queryByTestId("global-tooltip")).toBeNull();
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
expect(button).not.toHaveAttribute("title");
});
it("hides and gives the title back on the way out", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
hover(button);
await settle();
unhover(button);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("opens immediately on keyboard focus and closes on Escape", async () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
tabTo(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Refresh the working copy");
});
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
setup(
<button title="Raw value" data-tip-off>
opted out
</button>
);
const opted = screen.getByRole("button", { name: "opted out" });
hover(opted);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(opted).toHaveAttribute("title", "Raw value");
});
it("reads the title through a child element, and ignores an empty one", async () => {
setup(
<button title="">
<span data-testid="blank-child">blank</span>
</button>
);
hover(screen.getByTestId("blank-child"));
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
const button = screen.getByRole("button", { name: "refresh" });
button.innerHTML = "<span data-testid='deep'>refresh</span>";
hover(screen.getByTestId("deep"));
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
it("keeps an icon-only control's accessible name while its title is parked", async () => {
setup(
<button title="Stage this file">
<svg aria-hidden="true" />
</button>
);
const icon = screen.getByRole("button", { name: "Stage this file" });
tabTo(icon);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
expect(icon).not.toHaveAttribute("title");
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
fireEvent.keyDown(document, { key: "Escape" });
// Put back exactly as found: no aria-label left behind.
expect(icon).toHaveAttribute("title", "Stage this file");
expect(icon).not.toHaveAttribute("aria-label");
});
it("does not flicker while the pointer crosses the control's own children", async () => {
setup(
<button title="Two parts">
<span data-testid="part-a">a</span>
<span data-testid="part-b">b</span>
</button>
);
const a = screen.getByTestId("part-a");
const b = screen.getByTestId("part-b");
hover(a);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
// Still up, with no fresh delay.
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
});
it("hides when its control is removed from the page", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Going away">x</button>
</>
);
hover(screen.getByRole("button", { name: "x" }));
await settle();
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
rerender(
<>
<GlobalTooltip />
</>
);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// A poll flips the hint while the pointer rests on the control.
rerender(
<>
<GlobalTooltip />
<button title="Up to date">pull</button>
</>
);
await settle();
// The bubble must not keep advertising the old text.
expect(screen.queryByText("Pull 2 commits")).toBeNull();
unhover(button);
expect(button).toHaveAttribute("title", "Up to date");
});
it("leaves no title behind when React removed it while the bubble was up", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// React "removes" an attribute that is already parked: no MutationRecord.
rerender(
<>
<GlobalTooltip />
<button>pull</button>
</>
);
await settle();
unhover(button);
expect(button).not.toHaveAttribute("title");
expect(button).not.toHaveAttribute("data-tip");
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setup(<button title="Second control">second</button>);
// A click is pointer focus: :focus-visible does not match.
const button = screen.getByRole("button", { name: "refresh" });
await user.click(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.queryByTestId("global-tooltip")).toBeNull();
tabTo(screen.getByRole("button", { name: "second" }));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
});
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
render(
<>
<GlobalTooltip />
<div data-testid="scroller">
<button title="Inside the scroller">inside</button>
</div>
<div data-testid="elsewhere" />
</>
);
const button = screen.getByRole("button", { name: "inside" });
hover(button);
await settle();
fireEvent.scroll(screen.getByTestId("elsewhere"));
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
fireEvent.scroll(screen.getByTestId("scroller"));
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Inside the scroller");
});
it("hides when the document itself scrolls", async () => {
setup();
hover(screen.getByRole("button", { name: "refresh" }));
await settle();
fireEvent.scroll(document);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
});
+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>
+289 -159
View File
@@ -25,7 +25,7 @@ import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool } from "../../lib/types";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -77,11 +77,16 @@ export function AddLaneModal({
onClose,
onAdded,
cwdSuggestions,
prefill,
}: {
open: boolean;
onClose: () => void;
/** Called with the newly created (possibly still-provisioning) lane. */
onAdded: (lane: Lane) => void;
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
* A branch here means the sibling worktree checks out THAT branch, which
* is the existing-branch mode — the reason that mode exists (issue #34). */
prefill?: { sourceRepo?: string; branch?: string } | null;
/** The same suggestion list the Run form already fetched (dashboard cwd,
* home, recently-used paths) — reused rather than fetched a second time. */
cwdSuggestions: CwdSuggestion[];
@@ -92,13 +97,17 @@ export function AddLaneModal({
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
// Two ways to start a worktree lane, and git's own rule decides which is
// possible: "new" forks a branch off `base`, "existing" checks out a branch
// that is already there — which git allows exactly once across all of a
// repo's worktrees, hence the busy markers below.
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
const [router, setRouter] = useState("native");
const [routers, setRouters] = useState<AiRouter[]>([]);
const [routersLoaded, setRoutersLoaded] = useState(false);
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
const [busy, setBusy] = useState(false);
@@ -117,8 +126,9 @@ export function AddLaneModal({
setTitle("");
setBranch("");
setBranches(null);
setWorktrees([]);
setBranchMode("new");
setBase("");
setPipeline("default");
setRouter("native");
setRoutersLoaded(false);
setTool("claude");
@@ -128,23 +138,23 @@ export function AddLaneModal({
setSetupResult(null);
};
// The template a lane is created with is the ONLY chance to get it right
// from here: nothing else in the UI can change it afterwards, so a lane
// silently born on `default` renders an 8-node map for a 16-node workflow.
// Fetched on open (templates are file-backed and can change between opens).
// A prefill only ever seeds the fields ON OPEN: the user may then change
// anything, and re-applying it on every render would fight them for the box.
useEffect(() => {
if (!open || !prefill) return;
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
if (prefill.branch) {
setBranch(prefill.branch);
setBranchMode("existing");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// A session that boots on the vendor's API keeps talking to it for its whole
// life, so the router has to be picked here, before the lane exists.
useEffect(() => {
if (!open) return;
let cancelled = false;
api.lanes
.pipelines()
.then((r) => {
if (!cancelled) setPipelines(r.pipelines);
})
.catch(() => {
// Quiet: the select just falls back to the single `default` option
// below, and the lane still gets created.
if (!cancelled) setPipelines([]);
});
api.aiRouters
.list()
.then((r) => {
@@ -180,6 +190,7 @@ export function AddLaneModal({
const path = sourceRepo.trim();
if (!path) {
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(null);
return;
@@ -190,6 +201,7 @@ export function AddLaneModal({
const r = await api.lanes.branches(path);
if (lookedUpFor.current !== path) return; // a newer path superseded this one
setBranches(r.branches);
setWorktrees(r.worktrees || []);
setBase(r.current || r.branches[0] || "");
setBranchesError(null);
} catch {
@@ -197,6 +209,7 @@ export function AddLaneModal({
// Not yet a valid repo path (still being typed, or genuinely wrong) -
// quiet by design, the same way CwdAutocomplete never errors either.
setBranches(null);
setWorktrees([]);
setBase("");
setBranchesError(t("addLaneNotARepo"));
}
@@ -220,8 +233,9 @@ export function AddLaneModal({
const result = await api.lanes.ensure({
cwd: repo,
title: name || undefined,
pipeline,
tool,
// THIS directory, not a lane on a parent of it (#42).
exact: true,
});
// Report the lane the INSTANT it exists server-side. Everything after
// this is post-creation setup that can fail on its own, and letting one
@@ -229,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.
@@ -250,14 +276,17 @@ export function AddLaneModal({
return;
}
if (!branches || !name || !branch.trim()) return;
// A blank branch in "new" mode is legal and deliberate: the server names
// it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews.
if (!branches || !name || (branchMode === "existing" && !branch.trim())) return;
try {
const result = await api.lanes.worktree({
sourceRepo: repo,
title: name,
base: base || undefined,
branch: branch.trim(),
pipeline,
// An existing branch is checked out as it stands; `base` only means
// something when a branch is being forked.
base: branchMode === "new" ? base || undefined : undefined,
branch: branch.trim() || undefined,
tool,
});
onAdded(result.lane);
@@ -348,7 +377,16 @@ export function AddLaneModal({
const done = !!setupResult;
const disabled =
!done &&
(!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
(!sourceRepo.trim() ||
(mode === "worktree" &&
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
/** branch name -> the worktree directory already holding it. git allows one
* checkout per branch, so these are the branches this form cannot use. */
const busyBranches = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
return (
<ConfirmModal
@@ -422,6 +460,37 @@ export function AddLaneModal({
</p>
</div>
{/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */}
{mode === "worktree" && worktrees.length > 0 && (
<div
data-testid="add-lane-worktrees"
className="rounded-md border border-border-light bg-surface-0 p-2"
>
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
{t("addLaneWorktreesTitle", { count: worktrees.length })}
</p>
<ul className="space-y-0.5">
{worktrees.slice(0, 8).map((w) => (
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
<span className="shrink-0 font-mono text-fg-primary">
⑂ {w.branch || t("addLaneWorktreeDetached")}
</span>
<span className="truncate text-fg-muted" title={w.path}>
{w.path}
</span>
</li>
))}
{worktrees.length > 8 && (
<li className="text-[10px] text-fg-muted">
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
</li>
)}
</ul>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
@@ -431,152 +500,213 @@ export function AddLaneModal({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
{t("addLanePipelineLabel")}
</label>
<select
id="add-lane-pipeline"
value={pipeline}
onChange={(e) => setPipeline(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
(p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
)
)}
</select>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active = option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
)}
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
project usually wants the lane to BE the repo, working on
whatever is checked out (`main`) — which is exactly Repo
mode, so this jumps there rather than inventing a second way
to do it. git cannot check `main` out in a second worktree
while the repo itself holds it, so a worktree lane could not
offer this even if it wanted to. */}
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
)}
</div>
)}
{/* The router is the one per-lane extra this form still asks for, so it
sits in the form itself — a disclosure around a single field only
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
<div className="flex gap-2">
{(
[
{ value: "native", label: t("addLaneRouterNative") },
{ value: "router", label: t("addLaneRouterCustom") },
] as const
).map((option) => {
const active =
option.value === "native" ? router === "native" : router !== "native";
// Clicking "AI Router" before the list lands would resolve back to
// Native and read as a dead control.
const disabled = option.value === "router" && !routersLoaded;
return (
<label
key={option.value}
// No radio dot: the card itself lights up when selected. That
// leaves the DISABLED state with nothing to show it, since the
// greyed-out dot was carrying it — hence the explicit branch.
// The input only goes visually hidden, so keyboard and screen
// readers still get a real radio group, and the card carries
// its focus ring.
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
disabled
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
: active
? "cursor-pointer border-accent bg-accent/10 text-accent"
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="radio"
name="add-lane-router"
className="sr-only"
disabled={disabled}
checked={active}
onChange={() =>
// Switching to "AI Router" lands on the first saved
// profile; with none saved the radio stays on native and
// the select below explains why.
setRouter(
option.value === "native"
? "native"
: routers.find((r) => !r.builtin)?.name || "native"
)
}
/>
{option.label}
</label>
);
})}
</div>
{router !== "native" && (
<select
aria-label={t("addLaneRouterProfileLabel")}
value={router}
onChange={(e) => setRouter(e.target.value)}
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
>
{routers
.filter((r) => !r.builtin)
.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">
{!routersLoaded
? t("addLaneRouterLoading")
: routers.some((r) => !r.builtin)
? t("addLaneRouterHint")
: t("addLaneRouterEmptyHint")}
</p>
</fieldset>
</div>
</div>
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
@@ -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" />
)}
+17 -258
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,70 +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 { 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 {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
.catch(() => {
// Silent: a card that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip.
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId]);
return facts;
}
/**
* The lane's own application stack. Same shape and same silence as the git
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
* is the common case and not an error worth a banner.
*
* `bump` lets an up/down action re-read immediately instead of waiting out the
@@ -187,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([]);
});
@@ -469,141 +432,6 @@ 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,
@@ -624,22 +452,6 @@ export default function LaneCard({
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 = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
@@ -668,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]">
@@ -685,11 +499,8 @@ export default function LaneCard({
</div>
)}
{lane.needs_action && (
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
⚠ {lane.needs_action}
</div>
)}
{/* No needs-you banner here: the strip card already flags it, and the
console directly below is where the prompt actually waits. */}
{/* Runtime strip: the lane's own app stack, shown only for lanes that
declare a profile. A port that drifted from `base + slot` is called
@@ -765,58 +576,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>
)}
{/* 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"
@@ -831,7 +590,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,796 @@
/**
* @file The diff renderer behind the Changes panel (issue #35): one file's
* hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted,
* with the changed span inside a modified line picked out.
*
* Three deliberate choices:
* - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation
* view already ships. No new dependency for a panel that renders a few
* hundred lines.
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
* right for the case that matters (one edited span on a line), degrades to
* "the whole line changed" otherwise, and costs nothing.
* - Line pairing for split view happens here, on the rows the server already
* numbered. The server stays the single source of what changed; this file
* only decides how it looks.
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useContext,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
export type DiffLayout = "unified" | "split";
/**
* How tall a diff box may be. `fill` (the single-file view) takes the height
* its parent gives it. In the review list, which stacks many files, a diff
* grows to its full height so the PANEL is the one vertical scroller — a
* 60vh box per file nested inside it made hunk navigation scroll two boxes
* at once and land nowhere predictable. Only a windowed diff (above
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
* scroller.
*/
function boxHeight(fill: boolean, windowed: boolean) {
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
}
/**
* Bring `el` to the top of the scroller that actually scrolls it, below
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
* ancestor, the page included.
*/
function scrollToTop(el: HTMLElement, pad: number) {
let s = el.parentElement;
while (
s &&
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
)
s = s.parentElement;
if (!s) return;
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
}
/** Rows above which a view windows; at or below it renders exactly as before. */
export const WINDOW_THRESHOLD = 500;
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
export const ROW_PX = 16;
const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX };
/**
* The `[start, end)` item range to render for a scroll position. `scrollTop`
* is clamped to the content first, so a stale offset (content that shrank, a
* layout switch) still lands on real rows instead of an empty window.
*/
export function visibleRange(
scrollTop: number,
viewport: number,
rowHeight: number,
count: number,
overscan: number
) {
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
return { start: Math.min(start, end), end };
}
/**
* Windowing state for one scroller of `count` items. Off (the whole range)
* at or below the threshold.
*/
function useWindow(count: number) {
const [top, setTop] = useState(0);
const on = count > WINDOW_THRESHOLD;
// The offset is tracked even while windowing is off, so a diff that GROWS
// past the threshold (expanded context) windows where the reader is, not at
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
const lastTop = useRef(0);
// count 0 = no scroller here (split layout): the browser drops its offset,
// so coming back to unified must start from 0, not the pre-split row.
if (count === 0) lastTop.current = 0;
const [wasOn, setWasOn] = useState(on);
if (wasOn !== on) {
setWasOn(on);
setTop(on ? lastTop.current : 0);
}
// ponytail: the viewport is taken as the window height — never smaller than
// a box that lives inside the window, so it only over-renders a few dozen
// rows, and it needs no ResizeObserver to follow a panel resize.
const range = on
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
: { start: 0, end: count };
const onScroll = (el: HTMLElement) => {
lastTop.current = el.scrollTop;
if (on) setTop(el.scrollTop);
};
return { on, range, onScroll };
}
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
function longest(texts: Iterable<string>) {
let max = 0;
for (const t of texts) {
let n = t.length;
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
if (n > max) max = n;
}
return max;
}
/** Stands in for `rows` off-screen rows. */
function Spacer({ rows }: { rows: number }) {
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
}
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
function Windowed({
count,
range,
minWidth,
children,
}: {
count: number;
range: { start: number; end: number };
minWidth: string;
children: React.ReactNode;
}) {
return (
<div data-diff-content style={{ minWidth }}>
<Spacer rows={range.start} />
{children}
<Spacer rows={count - range.end} />
</div>
);
}
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) {
const ext = filePath.split(".").pop() || "";
return canonicalLang(ext === filePath ? "plain" : ext);
}
/** Tokenized line text. `emph` marks the span that actually changed. */
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) {
const [from, to] = emph;
return (
<>
<Code text={text.slice(0, from)} lang={lang} />
<span className="rounded-sm bg-fg-primary/15">
<Code text={text.slice(from, to)} lang={lang} />
</span>
<Code text={text.slice(to)} lang={lang} />
</>
);
}
return (
<>
{highlight(text, lang).map((tok, i) => (
<span key={i} className={tokenClass(tok.type)}>
{tok.text}
</span>
))}
</>
);
}
/**
* The changed span of a del/add pair, as `[start, end]` offsets into each
* side, or null when the lines share nothing worth pointing at.
*/
function emphasis(
del: string,
add: string
): { del: [number, number]; add: [number, number] } | null {
if (del === add) return null;
let start = 0;
const max = Math.min(del.length, add.length);
while (start < max && del[start] === add[start]) start += 1;
let end = 0;
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
// Nothing in common at either edge: the whole line is the change, and
// highlighting all of it adds noise on top of the row tint.
if (start === 0 && end === 0) return null;
return {
del: [start, del.length - end],
add: [start, add.length - end],
};
}
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
type EmphPair = { del: [number, number]; add: [number, number] };
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
* index in `hunk.lines` (what the unified view renders by). */
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
// columns and every re-render of the same data share one pass. A refetch
// brings new objects and the old entries go with them.
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
/** Pair each run of removed lines with the run of added lines following it,
* carrying indices so emphasis lands without searching the hunk again. */
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
const hit = analysed.get(hunk);
if (hit) return hit;
const lines = hunk.lines;
const rows: PairRow[] = [];
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
rows.push({ left: line, right: line, emph: null });
i += 1;
continue;
}
const delStart = i;
while (i < lines.length && lines[i]!.type === "del") i += 1;
const addStart = i;
while (i < lines.length && lines[i]!.type === "add") i += 1;
const dels = addStart - delStart;
const adds = i - addStart;
for (let k = 0; k < Math.max(dels, adds); k += 1) {
const left = k < dels ? lines[delStart + k]! : null;
const right = k < adds ? lines[addStart + k]! : null;
const e = left && right ? emphasis(left.text, right.text) : null;
if (e) {
emph.set(delStart + k, e.del);
emph.set(addStart + k, e.add);
}
rows.push({ left, right, emph: e });
}
}
const out = { rows, emph };
analysed.set(hunk, out);
return out;
}
function Gutter({ n }: { n: number | null }) {
return (
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
);
}
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
* `style` pins a windowed row's height; unwindowed rows carry none. */
function Row({
tint,
style,
children,
}: {
tint: string;
style?: CSSProperties;
children: React.ReactNode;
}) {
return (
<div
className={`flex w-max min-w-full ${tint}`}
style={style}
data-diff-item={style ? "" : undefined}
>
{children}
</div>
);
}
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
* separate from context at a glance on every theme (the /10 fill alone did not). */
function rowTint(type: LaneDiffLine["type"] | null) {
if (type === "add")
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
return "";
}
function markerClass(type: LaneDiffLine["type"]) {
if (type === "add") return "font-bold text-status-success";
if (type === "del") return "font-bold text-status-danger";
return "text-fg-muted";
}
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
* last changed line. A context rather than a prop because lines render from
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (hunk: LaneDiffHunk) => ReactNode;
hunks: LaneDiffHunk[];
go: (index: number) => void;
} | null>(null);
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
function lastChangedLine(hunk: LaneDiffHunk) {
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
if (hunk.lines[i]!.type !== "ctx") return i;
}
return -1;
}
function lastChangedRow(hunk: LaneDiffHunk) {
const rows = analyseHunk(hunk).rows;
for (let i = rows.length - 1; i >= 0; i -= 1) {
const r = rows[i]!;
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
}
return -1;
}
/** The review bar: hangs just BELOW the last changed line, pinned to the
* visible right edge, so it sits on the change it acts on — the editor-style
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
const index = ctx.hunks.indexOf(hunk);
const total = ctx.hunks.length;
const nav =
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
return (
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
<span
data-testid="lane-hunk-bar"
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
>
<button
type="button"
aria-label={t("changes.prevHunk")}
title={t("changes.prevHunk")}
disabled={index <= 0}
onClick={() => ctx.go(index - 1)}
className={nav}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
aria-label={t("changes.nextHunk")}
title={t("changes.nextHunk")}
disabled={index >= total - 1}
onClick={() => ctx.go(index + 1)}
className={nav}
>
<ChevronDown className="h-3 w-3" />
</button>
<span className="px-0.5 tabular-nums text-fg-muted">
{t("changes.hunkOf", { n: index + 1, total })}
</span>
{ctx.actions(hunk)}
</span>
</span>
);
}
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
const ctx = useContext(HunkActions);
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
<div
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
>
<span className="min-w-0 flex-1 truncate">
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</span>
</div>
);
}
function UnifiedLine({
line,
lang,
emph,
style,
bar,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
/** This is the hunk's last changed line: hang the review bar under it. */
bar?: LaneDiffHunk;
}) {
return (
<Row tint={rowTint(line.type)} style={style}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const { emph } = analyseHunk(hunk);
const last = lastChangedLine(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<UnifiedLine
key={i}
line={line}
lang={lang}
emph={emph.get(i)}
bar={i === last ? hunk : undefined}
/>
))}
</div>
);
}
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
* unified, a paired row in split. */
type Item = { hunk: LaneDiffHunk; i: number };
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
const out: Item[] = [];
for (const hunk of hunks) {
out.push({ hunk, i: -1 });
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
}
return out;
}
function longestInHunks(hunks: LaneDiffHunk[]) {
return longest(
(function* () {
for (const h of hunks) for (const l of h.lines) yield l.text;
})()
);
}
function SplitLine({
row,
lang,
side,
style,
bar,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
bar?: LaneDiffHunk;
}) {
const line = side === "left" ? row.left : row.right;
return (
<Row tint={rowTint(line?.type ?? null)} style={style}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code
text={line.text}
lang={lang}
emph={side === "left" ? row.emph?.del : row.emph?.add}
/>
) : null}
</span>
{bar && <HunkBar hunk={bar} />}
</Row>
);
}
/** One side of the split view: a hunk header, then that side's rows. */
function SplitColumn({
hunks,
lang,
side,
}: {
hunks: LaneDiffHunk[];
lang: string;
side: "left" | "right";
}) {
return (
<>
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} />
{analyseHunk(hunk).rows.map((row, i) => (
<SplitLine
key={i}
row={row}
lang={lang}
side={side}
// One bar per hunk: on the right (new) column only.
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
))}
</div>
))}
</>
);
}
/** A windowed split column: the shared range of items, one side of each. */
function SplitSlice({
items,
range,
lang,
side,
}: {
items: Item[];
range: { start: number; end: number };
lang: string;
side: "left" | "right";
}) {
return (
<>
{items
.slice(range.start, range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed />
) : (
<SplitLine
key={range.start + k}
row={analyseHunk(hunk).rows[i]!}
lang={lang}
side={side}
style={FIXED}
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
/>
)
)}
</>
);
}
/** `v` held inside a scroller's own extent. */
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
return Math.min(v, Math.max(0, scrollSize - clientSize));
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
];
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
function follow(src: HTMLElement, dst: HTMLElement) {
for (const [pos, size, client] of AXES) {
// `src` pinned at its own end while `dst` sits past it: `src` is the
// narrower column echoing a clamp we just caused. Pulling `dst` back to
// it would snap the wide column home every time it scrolls past the
// narrow one's longest line.
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
const target = clampScroll(src[pos], dst[size], dst[client]);
if (dst[pos] !== target) dst[pos] = target;
}
}
/** Both sides, each its own scroller but kept in step on both axes. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
const win = useWindow(items.length);
// Mirroring fires the other pane's own scroll handler, which mirrors back —
// the equality check (and `follow`'s edge rule) is what ends that, so no
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
// vertical drift puts a deletion beside some unrelated line. Both columns
// window off the same offset, so they always hold the same row range.
const sync = (from: "left" | "right") => {
const src = from === "left" ? left.current : right.current;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst) return;
follow(src, dst);
win.onScroll(src);
};
const column = (side: "left" | "right") =>
win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
</Windowed>
) : (
<SplitColumn hunks={hunks} lang={lang} side={side} />
);
return (
<div className={`flex ${boxHeight(fill, win.on)}`}>
<div
ref={left}
data-testid="lane-diff-split-left"
onScroll={() => sync("left")}
className="w-1/2 min-w-0 overflow-auto"
>
{column("left")}
</div>
<div
ref={right}
data-testid="lane-diff-split-right"
onScroll={() => sync("right")}
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
>
{column("right")}
</div>
</div>
);
}
/** One file's diff, in the chosen layout. */
export function LaneDiffView({
file,
layout,
fill = false,
hunkActions,
}: {
file: LaneChangeFile;
layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean;
/** Controls drawn in each hunk's review bar. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
}) {
const root = useRef<HTMLDivElement>(null);
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
const hunks = file.hunks;
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
const minWidth = useMemo(
// two gutters + the row's px-2, plus the marker column
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
[hunks]
);
// Unified only; split windows inside its own two scrollers.
const win = useWindow(layout === "unified" ? items.length : 0);
const splitWindowed = useMemo(
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
[layout, hunks]
);
// The bar's arrows: bring hunk `index`'s header into view. A windowed
// unified diff may not have it in the DOM, so there it is found by offset.
const go = (index: number) => {
const el = root.current;
if (!el || !hunks) return;
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
// The review list pins each file's header over its own diff.
const sticky = el
.closest("[data-review-file]")
?.querySelector<HTMLElement>("[data-sticky-top]");
const pad = sticky?.offsetHeight ?? 0;
if (header) {
scrollToTop(header, pad);
return;
}
// Windowed: the header is not in the DOM, but its offset is known.
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
if (!file.hunks || file.hunks.length === 0)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
return (
<HunkActions.Provider value={review}>
<div
ref={root}
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split"
? boxHeight(fill, splitWindowed)
: `overflow-auto ${boxHeight(fill, win.on)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
{items
.slice(win.range.start, win.range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
) : (
<UnifiedLine
key={win.range.start + k}
line={hunk.lines[i]!}
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
bar={i === lastChangedLine(hunk) ? hunk : undefined}
/>
)
)}
</Windowed>
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
))
)}
{file.truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
</HunkActions.Provider>
);
}
/** A whole file's text (no diff), for the viewer's "read the file" mode. */
export function LaneFileView({
path,
lines,
truncated,
fill = false,
}: {
path: string;
lines: string[];
truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */
fill?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
const win = useWindow(lines.length);
// gutter + the row's px-2
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
);
return (
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
>
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
{lines
.slice(win.range.start, win.range.end)
.map((text, k) => row(text, win.range.start + k, FIXED))}
</Windowed>
) : (
lines.map((text, i) => row(text, i))
)}
{truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
</div>
);
}
export default LaneDiffView;
+355
View File
@@ -0,0 +1,355 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, switch branch, 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 at the top of the Changes panel (`LaneGitHeader`), its actions as
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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" | "switch" | "branch-create" | "branch-delete";
function Btn({
label,
icon,
busy,
disabled,
title,
compact,
onClick,
}: {
label: string;
icon?: React.ReactNode;
busy?: boolean;
disabled?: boolean;
title?: string;
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
compact?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || busy}
title={title}
aria-label={compact ? label : undefined}
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
}`}
>
{icon}
{!compact && label}
</button>
);
}
export function LaneGitBar({
laneId,
git,
onChanged,
onNewLane,
lead,
}: {
laneId: number;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
lead?: React.ReactNode;
/** Called after any action that could have moved the working copy. */
onChanged: () => void;
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
* a branch this bar just created. Omitted where no such flow is reachable. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState<Action | null>(null);
const [error, setError] = useState<string | null>(null);
const [switching, setSwitching] = useState(false);
const [branches, setBranches] = useState<string[] | null>(null);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
// 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?: { branch?: string }) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
setBusy(action);
setError(null);
try {
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" || action.startsWith("branch-")) {
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
setBranches(null);
await refreshBranches();
}
onChanged();
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
if (laneNow.current === lane)
setError(
(err as { code?: string })?.code === "ENOREMOTE"
? t("gitBar.errNoRemote")
: err instanceof Error
? err.message
: String(err)
);
} finally {
if (laneNow.current === lane) {
inFlight.current = false;
setBusy(null);
}
}
};
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
} catch {
if (laneNow.current === laneId) setBranches([]);
}
};
const openSwitch = async () => {
setSwitching((v) => !v);
setError(null);
if (branches) return;
await refreshBranches();
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
return (
<div data-testid="lane-git-bar" className="space-y-1">
<div className="flex min-w-0 items-center gap-2">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{/* Null counts with an upstream named means git could not compare
the two (the upstream ref is gone): claiming "in sync" there
would be a lie, so it reads as not pushed. */}
{git.upstream && git.ahead != null && git.behind != null ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
>
{(git.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning">
<ArrowUp className="h-3 w-3" />
{git.ahead}
</span>
)}
{(git.behind ?? 0) > 0 && (
<span className="flex items-center text-accent">
<ArrowDown className="h-3 w-3" />
{git.behind}
</span>
)}
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
</span>
) : (
git.hasRemote && (
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
{t("gitBar.noUpstream")}
</span>
)
)}
<div className="ml-auto flex shrink-0 items-center">
<Btn
compact
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3.5 w-3.5" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
compact
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
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}
/>
</div>
</div>
{switching && (
<div className="flex items-center gap-1.5">
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
onChange={(e) => setPicked(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
const held = heldBy.get(b);
return (
<option key={b} value={b}>
{b}
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
</option>
);
})}
</select>
<Btn
label={t("gitBar.switchApply")}
busy={busy === "switch"}
// A branch git already has checked out somewhere else cannot be
// switched to — git allows one worktree per branch.
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
/>
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
// A branch any worktree holds — this lane's own included — is
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
title={
picked && (heldBy.has(picked) || picked === git.branch)
? t("gitBar.newLaneHeld", {
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
/>
)}
<Btn
label={t("gitBar.branchDelete")}
icon={<Trash2 className="h-3 w-3" />}
busy={busy === "branch-delete"}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
title={t("gitBar.branchDeleteHint")}
onClick={() => void run("branch-delete", { branch: picked })}
/>
{dirty > 0 && (
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
)}
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<input
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
}}
placeholder={t("gitBar.branchNewPlaceholder")}
aria-label={t("gitBar.branchNewPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.branchCreate")}
icon={<Plus className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// Creating does not switch: the point is usually a sibling lane,
// and moving this pane's HEAD under a working agent is not.
title={t("gitBar.branchCreateHint")}
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
/>
</div>
)}
{error && (
<pre
role="alert"
data-testid="lane-git-bar-error"
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
>
{error}
</pre>
)}
</div>
);
}
export default LaneGitBar;
@@ -0,0 +1,294 @@
/**
* @file The top of the Changes panel: the lane's working-copy line (branch,
* head, subject, and the uncommitted counts that open a per-file hover panel)
* and the git action bar under it (`LaneGitBar`).
*
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
* owned by the panel) and refresh each other after every write.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
/** How often the mounted panel re-reads its working-copy facts. Slow on
* purpose: each call is five git subprocesses server-side, and a branch name
* does not change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;
/**
* The lane's own working copy, fetched by the Changes panel rather than folded
* into the polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders with no git header.
*/
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
const [nonce, setNonce] = useState(0);
// A lane switch drops the previous lane's facts during render, so nothing
// (the git bar's "current branch" included) shows them until the new read.
const [factsLane, setFactsLane] = useState(laneId);
if (factsLane !== laneId) {
setFactsLane(laneId);
setFacts(null);
}
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
.catch(() => {
// Silent: a panel that cannot read git shows the rest of itself. An
// error banner here would fire on every lane on every server blip.
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
});
};
read();
const timer = setInterval(read, GIT_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
/** Porcelain status code -> the letter shown and the colour it is shown in.
* Keyed on the code's first meaningful character, so an index-side change
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
const GIT_STATUS_STYLE: Record<string, string> = {
M: "text-status-warning",
A: "text-status-success",
D: "text-status-danger",
R: "text-accent",
C: "text-accent",
U: "text-status-danger",
"?": "text-fg-muted",
};
/**
* The file list behind a lane's uncommitted counts: which files, what happened
* to each, and how many lines it gained and lost.
*
* It replaced a native `title` tooltip, which could show the same paths but
* neither colour them nor align the deltas — and a plain porcelain dump is not
* something you read at a glance.
*
* PORTALLED to `document.body` and `position: fixed`: the Changes panel
* scrolls in its own `overflow` box, and an absolutely positioned panel would
* be clipped by it. It flips above its anchor rather than off the bottom of
* the window, and clamps to the right edge.
*/
function LaneGitFilesPanel({
files,
hidden,
anchor,
onPointerEnter,
onPointerLeave,
}: {
files: LaneGitFile[];
hidden: number;
/** The counts button this panel hangs off. */
anchor: HTMLElement | null;
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// After the first paint, when the panel's real height is known: place it
// under the anchor, or above it when that would run off the bottom.
useLayoutEffect(() => {
if (!anchor || !panelRef.current) return;
const a = anchor.getBoundingClientRect();
const p = panelRef.current.getBoundingClientRect();
const gap = 4;
const top =
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
setPos({
top: Math.max(8, top),
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
});
}, [anchor, files]);
// Totals are over the LISTED files only — the cap means the rest are unknown,
// and a total that silently omits them would be a wrong number rather than a
// partial one. `hidden` says how many are missing.
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
return createPortal(
<div
ref={panelRef}
data-testid="lane-git-files"
role="group"
aria-label={t("git.filesHeading", { count: files.length })}
// The pointer has to be able to travel INTO the panel to scroll it, so
// the panel answers the same enter/leave pair the trigger does.
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
style={{
position: "fixed",
zIndex: 60,
top: pos?.top ?? 0,
left: pos?.left ?? 0,
// Hidden for the one frame before it has been measured, so it never
// flashes at the top-left corner.
visibility: pos ? "visible" : "hidden",
}}
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
>
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
<span className="ml-auto shrink-0 font-mono tabular-nums">
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
</span>
</div>
<ul className="space-y-0.5 font-mono text-[11px]">
{files.map((f) => {
const code = f.status.trim()[0] || "?";
return (
<li key={f.path} className="flex items-baseline gap-2">
<span
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
title={f.status}
>
{code}
</span>
{/* The DIRECTORY truncates, never the filename: the name is what
you scan for, the path is only there to disambiguate it. */}
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
</span>
<span className="shrink-0 tabular-nums">
{f.added === null && f.deleted === null ? (
<span className="text-fg-muted">{t("git.noDelta")}</span>
) : (
<>
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
<span className="text-status-danger">−{f.deleted ?? 0}</span>
</>
)}
</span>
</li>
);
})}
</ul>
{hidden > 0 && (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
)}
</div>,
document.body
);
}
export function LaneGitHeader({
laneId,
git,
onChanged,
onNewLane,
}: {
laneId: number;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
/** See `LaneGitBar`'s prop of the same name. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
// Closing is DELAYED: the panel is portalled away from the trigger, so the
// pointer leaves the trigger before it can land on the panel it is heading
// for. Any enter — trigger or panel — cancels the pending close.
const filesCloseTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
if (!git?.available) return null;
const lead = (
<>
{/* One row with the actions: branch first and strongest, then the head
and its subject, which is what truncates when the panel is narrow
(the whole of it stays in the tooltip). */}
<div
data-testid="lane-git"
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
title={`${git.head} ${git.subject}`}
>
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<GitBranch className="h-3 w-3 text-fg-muted" />
{git.branch}
</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<button
type="button"
ref={setCountsAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
onClick={() => setFilesOpen((v) => !v)}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</button>
)}
</>
);
return (
<div
data-testid="lane-git-header"
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
>
<LaneGitBar
laneId={laneId}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
lead={lead}
/>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div>
);
}
@@ -109,18 +109,6 @@ async function fillWorktreeForm(
beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.pipelines)
.mockReset()
.mockResolvedValue({
pipelines: [
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
{
id: "ship-feature",
name: "Ship feature (lane pipeline)",
nodes: new Array(16).fill({ id: "n" }),
},
],
});
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
@@ -181,8 +169,102 @@ function savedRouter(over: Record<string, unknown> = {}) {
};
}
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
const withWorktrees = {
branches: ["main", "feat/other", "feat/held"],
current: "main",
worktrees: [
{ path: "/Users/tester/projects/repo", branch: "main" },
{ path: "/lanes/repo__held", branch: "feat/held" },
],
};
it("lists the repo's existing worktrees once the path resolves", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
const panel = await screen.findByTestId("add-lane-worktrees");
expect(panel).toHaveTextContent("/lanes/repo__held");
expect(panel).toHaveTextContent("feat/held");
});
it("checks an existing branch out, sending no base", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Reuse a branch");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const select = await screen.findByLabelText("Branch to check out");
await user.selectOptions(select, "feat/other");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: "feat/other", base: undefined })
)
);
});
it("greys out a branch another worktree already holds", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const held = await screen.findByRole("option", { name: /feat\/held/ });
expect(held).toBeDisabled();
expect(held).toHaveTextContent("/lanes/repo__held");
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
});
it("lets the server name a new branch when the field is left empty", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Auto named");
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
// the form cannot see, so the placeholder only says the field is optional.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"Automatic — named from the title"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" })
)
);
});
});
describe("AddLaneModal — worktree mode (default)", () => {
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
// The branch NAME is no longer part of this list: an empty one means "let
// the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
@@ -191,6 +273,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main", "feat/other"],
current: "main",
worktrees: [],
});
renderModal();
const user = userEvent.setup();
@@ -223,7 +306,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn();
const onClose = vi.fn();
@@ -239,7 +326,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
title: "New feature",
base: "main",
branch: "feat/new-feature",
pipeline: "default",
tool: "claude",
});
});
@@ -253,7 +339,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
const onClose = vi.fn();
renderModal({ onClose });
@@ -272,7 +362,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
});
@@ -291,7 +385,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("still calls onAdded even when every setup call fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
});
@@ -311,7 +409,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
});
@@ -334,7 +436,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
});
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
});
@@ -397,8 +503,8 @@ describe("AddLaneModal — repo mode (adopt)", () => {
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/Users/tester/projects/repo",
title: "main repo",
pipeline: "default",
tool: "claude",
exact: true,
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
@@ -407,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", () => {
@@ -591,50 +719,6 @@ describe("AddLaneModal — a router profile that disappeared", () => {
});
});
describe("AddLaneModal — pipeline template", () => {
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
// Creation is the ONLY point the UI can set a template, so a lane born on
// `default` renders 8 nodes for a 16-node workflow with no way back from
// any screen.
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() =>
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
);
expect(
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
).toBeInTheDocument();
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.selectOptions(select, "ship-feature");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(
expect.objectContaining({ pipeline: "ship-feature" })
)
);
});
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
renderModal();
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() => expect(select).toHaveValue("default"));
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
});
});
describe("AddLaneModal — tool selection", () => {
it("sends the picked tool when adopting a repo", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
@@ -679,3 +763,41 @@ describe("AddLaneModal — tool selection", () => {
);
});
});
describe("AddLaneModal — working straight on main (no worktree)", () => {
beforeEach(() => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["development", "main"],
current: "development",
worktrees: [],
});
});
it("jumps to Repo mode from the branch choice", async () => {
const user = userEvent.setup();
renderModal();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByTestId("add-lane-branch-section");
await user.click(screen.getByRole("button", { name: "Current branch" }));
// Repo mode adopts the directory as it stands — no branch section left to
// answer, because there is no fork to configure.
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
});
it("prefills the repo and branch it was opened with", async () => {
renderModal({
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
});
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
});
@@ -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";
@@ -167,9 +167,9 @@ describe("LaneCard rebuilt layout", () => {
expect(screen.queryByText("48%")).toBeNull();
});
it("surfaces a needs-you message", () => {
it("leaves the needs-you message to the strip card", () => {
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
expect(screen.queryByText(/waiting on approval/)).toBeNull();
});
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
@@ -204,120 +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 },
],
};
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();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
/**
* @file LaneDiffView.test.tsx
* @description Pins the side-by-side renderer: two independently scrolling
* columns (a long line must be reachable, not painted over its neighbour)
* whose horizontal scroll is mirrored, so the two halves of a line stay
* opposite each other. Also pins the clamped mirroring (a narrow column may
* not drag the wide one back) and the row windowing of large diffs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import {
LaneDiffView,
LaneFileView,
ROW_PX,
WINDOW_THRESHOLD,
analyseHunk,
visibleRange,
} from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 1,
deleted: 1,
binary: false,
hunks: [
{
header: "@@ -1,2 +1,2 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
],
},
],
};
/** jsdom has no layout: give a scroller the extents a browser would. */
function layout(
el: HTMLElement,
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
) {
for (const [k, v] of Object.entries(g))
Object.defineProperty(el, k, { value: v, configurable: true });
}
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
describe("LaneDiffView — side by side", () => {
it("gives each column its own scroller", () => {
render(<LaneDiffView file={FILE} layout="split" />);
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
});
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
// windowed one keeps the cap because windowing needs its own scroller.
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
const big: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
type: "ctx" as const,
oldLine: i + 1,
newLine: i + 1,
text: `line ${i}`,
})),
},
],
};
rerender(<LaneDiffView file={big} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
it("mirrors one column's horizontal scroll onto the other", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollLeft = 320;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(320);
right.scrollLeft = 0;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(0);
});
it("keeps the split columns on the same row vertically too", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollTop = 480;
fireEvent.scroll(left);
expect(right.scrollTop).toBe(480);
right.scrollTop = 40;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(40);
});
it("clamps the mirrored offset to the other column's own extent", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
right.scrollLeft = 600;
right.scrollTop = 250;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(300);
expect(left.scrollTop).toBe(100);
});
it("does not let the narrow column's clamped echo drag the wide one back", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200 });
right.scrollLeft = 600;
fireEvent.scroll(right);
// A browser fires the narrow column's scroll for the clamped write.
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(600);
// A real move of the narrow column still carries the wide one.
left.scrollLeft = 100;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(100);
});
it("keeps the unified layout on one scroller", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
});
});
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
}
const ROWS = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
]);
/** The unified row whose text contains `needle`. */
function rowOf(container: HTMLElement, needle: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
r.textContent?.includes(needle)
);
if (!row) throw new Error(`no row for ${needle}`);
return row;
}
describe("LaneDiffView — row styling", () => {
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const add = rowOf(container, "x = 2");
const del = rowOf(container, "x = 1");
const ctx = rowOf(container, "keep()");
expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger");
expect(ctx.className).not.toMatch(/status-(success|danger)/);
});
it("colours the +/- marker bold in its status colour, context marker muted", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "x = 2"));
const minus = marker(rowOf(container, "x = 1"));
const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold");
expect(plus.className).toContain("text-status-success");
expect(minus.textContent).toBe("-");
expect(minus.className).toContain("text-status-danger");
expect(ctx.className).toContain("text-fg-muted");
expect(ctx.className).not.toContain("font-bold");
});
it("emphasises only the changed span of a paired del/add", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
(e) => e.textContent
);
expect(emph).toEqual(["1", "2"]);
});
it("does not emphasise a line that changed completely", () => {
const { container } = render(
<LaneDiffView
file={fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
])}
layout="unified"
/>
);
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
});
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
render(
<LaneDiffView
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
layout="split"
/>
);
const rows = (id: string) => [
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
];
const [right] = rows("lane-diff-split-right");
const [left] = rows("lane-diff-split-left");
expect(right!.className).toContain("bg-status-success/20");
expect(left!.className).not.toMatch(/status-(success|danger)/);
});
});
describe("analyseHunk", () => {
const hunk = {
header: "@@ -1,4 +1,4 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
],
};
it("pairs each del/add with its emphasis, keyed by line index", () => {
const a = analyseHunk(hunk);
expect(a.rows).toHaveLength(3);
expect(a.emph.get(1)).toEqual([6, 7]);
expect(a.emph.get(2)).toEqual([6, 7]);
expect(a.emph.get(3)).toEqual([6, 7]);
expect(a.emph.get(4)).toEqual([6, 7]);
expect(a.emph.has(0)).toBe(false);
});
it("computes once per hunk, so both split columns and re-renders share it", () => {
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
});
});
describe("visibleRange", () => {
it("covers the viewport plus overscan on both sides", () => {
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
});
it("clamps a scrollTop past the content to the last full viewport", () => {
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
});
it("never runs past a short list", () => {
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
});
});
function bigFile(n: number): LaneChangeFile {
const lines = Array.from({ length: n }, (_, i) =>
i % 2
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
);
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
return fileWith(lines);
}
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
describe("LaneDiffView — windowing", () => {
it("renders every row below the threshold, with no spacers", () => {
const { container } = render(
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
);
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
});
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const rows = rowsIn(container);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(300);
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
// header + 5000 lines, every item one fixed row tall
const rendered = container.querySelectorAll("[data-diff-item]").length;
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
});
it("moves the window with the scroll", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
expect(container.textContent).not.toContain("line 3000;");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("line 3000;");
expect(container.textContent).not.toContain("line 10;");
});
it("starts windowing where the reader is when a diff grows past the threshold", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 350 * ROW_PX;
fireEvent.scroll(view);
// Expanding context replaces the file in place, same mounted view.
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
expect(container.textContent).toContain("line 350;");
});
it("starts from the top again after a round trip through split", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
// In split the outer box is no scroller; the browser drops its offset.
screen.getByTestId("lane-diff-view").scrollTop = 0;
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
expect(container.textContent).toContain("line 5;");
});
it("holds the horizontal extent at the longest line, rendered or not", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
// the 300-w line 7 is off screen now, and still sets the width
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
});
it("windows both split columns over the same row range", () => {
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
right.scrollTop = 2000 * ROW_PX;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(2000 * ROW_PX);
const l = rowsIn(left);
const r = rowsIn(right);
expect(l.length).toBeLessThan(300);
expect(l).toHaveLength(r.length);
expect(right.textContent).toContain("line 2001;");
// same rows: the right column's add has its filler row on the left
expect(left.textContent).toContain("line 2000;");
});
it("windows a whole-file read too", () => {
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
expect(rowsIn(container).length).toBeLessThan(300);
const view = screen.getByTestId("lane-file-view");
view.scrollTop = 4000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("row 4000");
});
});
describe("LaneDiffView — hunk review bar navigation", () => {
const hunk = (n: number) => ({
header: `@@ -${n},1 +${n},1 @@`,
context: "",
oldStart: n,
newStart: n,
lines: [
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
],
});
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
function renderInScroller() {
const view = render(
<div data-testid="scroller" style={{ overflowY: "auto" }}>
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
</div>
);
const scroller = screen.getByTestId("scroller");
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
Object.defineProperty(scroller, "clientHeight", { value: 100 });
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
const k = Number(el.dataset.hunkIndex);
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
}
return scroller;
}
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
const scroller = renderInScroller();
const next = screen.getAllByRole("button", { name: "Next change" });
const prev = screen.getAllByRole("button", { name: "Previous change" });
fireEvent.click(next[0]!); // bar 1 → hunk 2
expect(scroller.scrollTop).toBe(100);
fireEvent.click(next[1]!); // bar 2 → hunk 3
expect(scroller.scrollTop).toBe(200);
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
expect(scroller.scrollTop).toBe(100);
});
it("counts the hunks and disables the arrows at either end", () => {
renderInScroller();
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
});
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
render(<LaneDiffView file={three} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
});
@@ -0,0 +1,257 @@
/**
* @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 it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
return {
available: true,
branch: "feat/thing",
head: "9b3e74a",
subject: "wip",
dirty: 2,
untracked: 1,
files: [],
hasRemote: true,
upstream: "origin/feat/thing",
ahead: 2,
behind: 1,
...over,
};
}
beforeEach(() => {
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
action: "fetch",
ok: true,
output: "",
});
vi.mocked(api.lanes.gitBranches)
.mockReset()
.mockResolvedValue({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [],
});
});
describe("LaneGitBar", () => {
it("shows both sides of the upstream comparison", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
const tracking = screen.getByTestId("lane-git-tracking");
expect(tracking).toHaveTextContent("2");
expect(tracking).toHaveTextContent("1");
});
it("disables the network actions when the repo has no remote", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ hasRemote: false, upstream: null })}
onChanged={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
// Local actions stay available — a repo with no remote still branches.
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
});
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
// Picking is harmless; applying is what a dirty tree blocks.
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("switches branch on a clean tree", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await user.selectOptions(select, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
);
});
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
render(
<LaneGitBar
laneId={3}
git={facts({ dirty: 0, untracked: 0 })}
onChanged={vi.fn()}
onNewLane={onNewLane}
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "feat/sibling",
})
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "New lane here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing", "feat/free"],
current: "feat/thing",
worktrees: [
{ path: "/repo/lane3", branch: "feat/thing" },
{ path: "/repo/dev", branch: "development" },
],
});
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const newLane = () => screen.getByRole("button", { name: "New lane here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await user.selectOptions(select, "feat/free");
expect(newLane()).toBeEnabled();
});
it("prints git's own rejection text when an action fails", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
});
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
});
it("sends one branch-create for a double Enter", async () => {
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
});
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
new Promise((r) => {
resolveOld = r;
})
);
const user = userEvent.setup();
const { rerender } = render(
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
const newLane = screen.getByRole("button", { name: "New lane here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
});
it("claims no sync state when the upstream comparison could not be counted", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
onChanged={vi.fn()}
/>
);
expect(screen.queryByText("in sync")).toBeNull();
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
});
it("explains a push with nowhere to go instead of the server's generic text", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
"No remote to push to or pull from"
);
});
});
@@ -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();
}
});
});
+152 -29
View File
@@ -15,10 +15,20 @@
* swaps the whole pane over to the new lane — its cwd, its tool, its history,
* and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header.
*
* Each pane is one tmux session on its lane — split panes all show the same
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
* that and attach to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
* reachable and the user did not ask to kill it. `killRun` is the other half:
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
* DOES want that previous session gone so a fresh start/resume can proceed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api";
@@ -38,6 +48,20 @@ import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
/** The claude lane a session's cwd belongs to: the longest path-boundary
* prefix, the same rule the server binds hook sessions with. External
* sessions are all claude — Codex emits no hooks. */
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
let best: Lane | null = null;
for (const l of lanes) {
if (l.tool !== "claude") continue;
const root = l.cwd.replace(/\/+$/, "");
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
}
return best?.id ?? null;
}
export interface LaneConsolePaneProps {
lanes: Lane[];
laneId: number | null;
@@ -53,6 +77,19 @@ export interface LaneConsolePaneProps {
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
/** Which split pane this is. Every pane shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
slot?: number;
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
* one terminal is never attached twice side by side. */
hiddenRunIds?: string[];
/** Reports the run this pane shows (null on the setup form). */
onHandleChange?: (runId: string | null) => void;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
export function LaneConsolePane({
@@ -68,6 +105,9 @@ export function LaneConsolePane({
wsConnected,
defaultCwd,
onHasActiveRunChange,
slot = 0,
hiddenRunIds,
onHandleChange,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -88,12 +128,37 @@ export function LaneConsolePane({
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude";
// The page polls runs and sessions for EVERY lane; this pane's Active runs
// list is this lane's only. No lane selected keeps the full lists.
const laneRuns = useMemo(
() =>
laneId === null || !activeRuns
? activeRuns
: {
...activeRuns,
items: activeRuns.items.filter(
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
),
},
[activeRuns, laneId, hiddenRunIds]
);
const laneSessions = useMemo(
() =>
laneId === null
? externalSessions
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
useEffect(() => {
onHandleChange?.(handle?.id ?? null);
}, [handle, onHandleChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
@@ -132,10 +197,11 @@ export function LaneConsolePane({
setResumeSession(null);
setError(null);
setBusy(null);
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
const own = laneId === null ? null : paneRunId(laneId, slot);
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
autoAttachedForLane.current = null;
refreshList();
}, [laneId, refreshList]);
}, [laneId, slot, refreshList]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
@@ -152,12 +218,13 @@ export function LaneConsolePane({
useEffect(() => {
if (handle || laneId === null) return;
if (autoAttachedForLane.current === laneId) return;
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
const own = paneRunId(laneId, slot);
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
if (running) {
autoAttachedForLane.current = laneId;
setHandle(running);
}
}, [activeRuns, laneId, handle]);
}, [activeRuns, laneId, slot, handle]);
// The cwd tracks the lane's own folder separately, keyed on the resolved
// path rather than on `laneId` alone: the pane can mount before the lane
@@ -236,6 +303,7 @@ export function LaneConsolePane({
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
@@ -246,29 +314,31 @@ export function LaneConsolePane({
resumeLast: args.resumeLast,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
return;
}
// A code-only check: the server's message is human prose ("lane
// already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
// This pane's own session is the live one — land on it.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const fetched = await api.run.get(laneStartResult.lane.run_id);
const fetched = await api.run.get(startedId);
setHandle(fetched);
refreshList();
} catch {
try {
await attachToRun(laneStartResult.lane.run_id);
await attachToRun(startedId);
refreshList();
} catch (fallbackErr: unknown) {
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
@@ -282,7 +352,18 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
[
busy,
t,
lanes,
laneId,
laneTool,
slot,
onLaneIdChange,
onLaneCreated,
attachToRun,
refreshList,
]
);
const onResumeFromHistory = useCallback(
@@ -307,19 +388,38 @@ export function LaneConsolePane({
onLaneCreated(ensureResult.lane);
}
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
} catch (laneErr: unknown) {
// The lane's own pane is still busy with a PREVIOUS live session —
// that session is still active and reachable, so land on it
// instead of failing outright. Killing it (Active runs → Kill) is
// what actually frees the lane for this resume. Checked by `.code`
// — the server's `.message` is prose, never the literal string
// "ERUNLIVE" or "409".
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
onLaneIdChange(targetLaneId);
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error("No run_id returned from lane start");
}
fetched = await api.run.get(laneStartResult.lane.run_id);
fetched = await api.run.get(startedId);
onLaneIdChange(targetLaneId);
} else {
fetched = await api.run.start({
@@ -343,7 +443,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const onViewFromHistory = useCallback(
@@ -360,6 +460,28 @@ export function LaneConsolePane({
setError(null);
}, []);
// Frees a lane blocked by a live run nobody wants anymore (see Active
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
const killRun = useCallback(
async (id: string) => {
setBusy("kill");
setError(null);
try {
await api.run.kill(id);
if (handle?.id === id) newRun();
refreshList();
} catch (err) {
setError(
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
);
} finally {
setBusy(null);
}
},
[handle, newRun, refreshList, t]
);
if (laneId === null && showLaneSelector) {
return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
@@ -431,13 +553,14 @@ 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}
onRefresh={refreshList}
/>
</header>
+53 -6
View File
@@ -10,18 +10,23 @@
* - `RunsModal` — the merged list itself: live in-memory handles from
* `activeRuns` deduped against persistent `runHistory`, newest first, with
* status / mode chip filters, a free-text search, and the per-row Attach /
* Resume / View actions.
* Resume / View / Kill actions.
*
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "Active runs" counts everything actually running. They carry no tmux session
* the dashboard can attach to, so their only action is Resume, which spawns a
* fresh tmux-backed `claude --resume <session>` in that cwd.
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
* from them for the same reason.
*
* Props only: no API call of its own. The page passes `activeRuns`,
* `runHistory` and `externalSessions` in and gets attach / resume / view /
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
* just calls `onRefresh`.
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
* runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
* discarding a file in the Changes panel) is what actually frees a lane whose
* pane is still busy with a previous session — `LaneConsolePane` needs it
* because starting or resuming into a lane already holding a live run answers
* 409 ERUNLIVE rather than overwriting a prompt in flight.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -39,6 +44,7 @@ import {
Search,
RotateCcw,
Eye,
Square,
} from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import type { Session } from "../../lib/types";
@@ -52,7 +58,7 @@ function StatusPill({
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
externalSessions = [],
onResumeFromHistory,
onViewFromHistory,
onKill,
onRefresh,
}: {
activeRuns: RunListResponse | null;
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
externalSessions?: Session[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
* spawned rows only — an external session has no tmux session here to
* kill. */
onKill: (id: string) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation("run");
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
setOpen(false);
onViewFromHistory(item);
}}
onKill={onKill}
runHistory={historyItems}
onClose={() => setOpen(false)}
onRefresh={onRefresh}
@@ -273,6 +285,7 @@ export function RunsModal({
onAttach,
onResume,
onView,
onKill,
runHistory,
onClose,
onRefresh,
@@ -282,6 +295,7 @@ export function RunsModal({
onAttach: (id: string) => void;
onResume: (item: DashboardRunHistoryItem) => void;
onView: (item: DashboardRunHistoryItem) => void;
onKill: (id: string) => void;
runHistory: DashboardRunHistoryItem[];
onClose: () => void;
onRefresh: () => void;
@@ -446,6 +460,7 @@ export function RunsModal({
const h = historyById.get(r.id);
if (h) onView(h);
}}
onKill={() => onKill(r.id)}
/>
);
})
@@ -512,14 +527,20 @@ function UnifiedRunRowView({
onAttach,
onResume,
onView,
onKill,
}: {
row: UnifiedRunRow;
isCurrent: boolean;
onAttach: () => void;
onResume: () => void;
onView: () => void;
onKill: () => void;
}) {
const { t } = useTranslation("run");
// Two clicks before the tmux session actually dies — same convention as
// discarding a file's changes in the Changes panel, for the one action
// here that cannot be undone.
const [confirmKill, setConfirmKill] = useState(false);
const startedDate = new Date(row.startedAt);
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
@@ -553,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")}
@@ -593,6 +614,32 @@ function UnifiedRunRowView({
{t("runs.viewLabel", "View")}
</button>
)}
{row.isLive && !row.external && (
<button
onClick={() => {
if (!confirmKill) {
setConfirmKill(true);
return;
}
setConfirmKill(false);
onKill();
}}
onBlur={() => setConfirmKill(false)}
title={
confirmKill
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
: t("runs.killLabel", "Kill")
}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
confirmKill
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
}`}
>
<Square className="w-3 h-3" />
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
</button>
)}
</span>
</div>
{row.promptPreview && (
+283 -18
View File
@@ -36,6 +36,7 @@ import {
ShieldAlert,
X,
FolderOpen,
GitBranch,
Home,
History as HistoryIcon,
Search,
@@ -52,45 +53,305 @@ import type {
RunStartArgs,
CodexSandbox,
CodexApproval,
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { Select } from "../Select";
// Minimal SlashCommand type (from deleted RunConsole)
export interface SlashCommand {
name: string;
source: "project" | "user" | "plugin" | "builtin";
source: "project" | "user" | "builtin";
description?: string;
}
// Minimal PromptEditor component (from deleted RunConsole)
// Commands the CLI answers itself. They are typed into the pane like any
// other text, so they do run — they are just not files anyone can list.
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
"help",
"clear",
"compact",
"config",
"model",
"memory",
"hooks",
"cost",
"agents",
"review",
"permissions",
"status",
"init",
"mcp",
"plugin",
"output-style",
"exit",
].map((name) => ({ name, source: "builtin" }));
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
project: "text-status-success border-status-success/40",
user: "text-accent border-accent/40",
builtin: "text-fg-muted border-border",
};
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
* description (3+ chars only, so one keystroke drags nothing tangential in). */
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
if (!q) return 1;
const n = name.toLowerCase();
if (n === q) return 1000;
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
const idx = n.indexOf(q);
if (idx >= 0) return 400 - Math.min(idx, 100);
let i = 0;
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
if (i === q.length) return 200;
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
return 0;
}
interface Trigger {
kind: "slash" | "file";
query: string;
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
start: number;
cursor: number;
}
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
function detectTrigger(value: string, cursor: number): Trigger | null {
let start = cursor;
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
const tok = value.slice(start, cursor);
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
return null;
}
interface PromptEditorProps {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
rows?: number;
slashCommands: SlashCommand[];
/** Offer slash commands. Off for codex: these are Claude Code's. */
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string;
}
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
function PromptEditor({
value,
onChange,
onSubmit,
placeholder,
rows = 5,
slash,
fileCwd,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
if (commandsCwd !== fileCwd) {
setCommandsCwd(fileCwd);
setCommands(null);
}
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
// Commands and skills are read the first time `/` is typed for this cwd —
// two directory walks nobody should pay for on every render of the form.
useEffect(() => {
if (kind !== "slash" || commands) return;
let live = true;
const toCmd = (c: CcMdItem): SlashCommand => ({
name: c.name,
source: c.scope,
description: c.frontmatter?.description || c.preview.slice(0, 80),
});
Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
]).then(([c, sk]) => {
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
});
return () => {
live = false;
};
}, [kind, commands, fileCwd]);
// Files: debounced, and only the newest query may land.
const query = trigger?.query ?? "";
useEffect(() => {
if (kind !== "file") return;
let live = true;
const tid = window.setTimeout(() => {
api.run
.files(fileCwd, query)
.then((r) => live && setFiles(r.items))
.catch(() => live && setFiles([]));
}, 120);
return () => {
live = false;
window.clearTimeout(tid);
};
}, [kind, query, fileCwd]);
const slashItems = useMemo(() => {
if (kind !== "slash" || !commands) return [];
const q = query.toLowerCase();
return commands
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
.filter((x) => x.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
a.cmd.name.localeCompare(b.cmd.name)
)
.map((x) => x.cmd);
}, [kind, commands, query]);
const count = kind === "file" ? files.length : slashItems.length;
const current = Math.min(active, Math.max(0, count - 1));
const pick = (token: string) => {
if (!trigger) return;
const before = value.slice(0, trigger.start);
const after = value.slice(trigger.cursor);
const sep = after.startsWith(" ") ? "" : " ";
onChange(before + token + sep + after);
setTrigger(null);
setActive(0);
const pos = before.length + token.length + 1;
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.setSelectionRange(pos, pos);
});
};
const pickAt = (i: number) => {
if (kind === "file") {
const f = files[i];
if (f) pick(`@${f}`);
} else {
const c = slashItems[i];
if (c) pick(`/${c.name}`);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
if (kind && count > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const step = e.key === "ArrowDown" ? 1 : -1;
setActive(Math.min(count - 1, Math.max(0, current + step)));
return;
}
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
pickAt(current);
return;
}
}
if (kind && e.key === "Escape") {
e.preventDefault();
setTrigger(null);
return;
}
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSubmit();
}
};
const retrack = (ta: HTMLTextAreaElement) => {
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
};
const rowClass = (i: number) =>
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
<div className="relative">
<textarea
ref={taRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
retrack(e.target);
setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
{kind && (
<div
role="listbox"
data-testid="prompt-autocomplete"
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
>
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
</div>
{count === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
</div>
) : kind === "slash" ? (
slashItems.map((c, i) => (
<button
key={`${c.source}:${c.name}`}
type="button"
role="option"
aria-selected={i === current}
// Keep focus in the textarea, or its blur closes the list first.
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={rowClass(i)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
>
{c.source}
</span>
</div>
{c.description && (
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
)}
</button>
))
) : (
files.map((f, i) => (
<button
key={f}
type="button"
role="option"
aria-selected={i === current}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
>
{f}
</button>
))
)}
</div>
)}
</div>
);
}
@@ -119,7 +380,6 @@ interface RunSetupProps {
* sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */
laneCwd?: string;
slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
/** The selected lane's CLI. Decides which advanced fields render. */
@@ -284,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">
@@ -449,7 +709,10 @@ export function CwdAutocomplete({
// Group suggestions by kind preserving fixed order — home first, matching
// the neutral default the page pre-fills (issue #202).
const groups = useMemo(() => {
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
// "repo" (source repos lanes were cut from) sits above the recents: on the
// Add-lane form it is the group the field is actually asking for, and on
// the Run form it is a short, deliberate list rather than history.
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"];
return order
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
.filter((g) => g.items.length > 0);
@@ -525,6 +788,8 @@ export function CwdAutocomplete({
<FolderOpen className="w-3 h-3" />
) : g.kind === "home" ? (
<Home className="w-3 h-3" />
) : g.kind === "repo" ? (
<GitBranch className="w-3 h-3" />
) : (
<HistoryIcon className="w-3 h-3" />
)}
@@ -683,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"
+106 -2
View File
@@ -9,13 +9,25 @@
* The socket auto-reconnects: a server restart kills the attach PTY, and
* without a retry the pane silently freezes on its last painted frame — it
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
* and its PATH pasted into the pane — what a local terminal does on a drop,
* and how `claude` attaches an image.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
interface TerminalViewProps {
runId: string;
@@ -33,6 +45,50 @@ 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);
const { t } = useTranslation("run");
const [drop, setDrop] = useState<DropState | null>(null);
// dragenter/dragleave fire for every child crossed, so count them: the hint
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (state: DropState) => {
setDrop(state);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setDrop(null), 2500);
};
useEffect(
() => () => {
if (flashTimer.current) clearTimeout(flashTimer.current);
},
[]
);
const onDrop = async (e: DragEvent) => {
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return flash("imagesOnly");
setDrop("uploading");
try {
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees one pasted path rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
flash("failed");
}
};
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -56,7 +112,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 +283,54 @@ 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
className="relative h-full w-full"
onDragEnter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current += 1;
setDrop("hint");
}}
onDragOver={(e) => {
if (!hasFiles(e)) return;
e.preventDefault(); // without it the browser refuses the drop
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!hasFiles(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
}}
onDrop={(e) => void onDrop(e)}
>
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
{drop && (
// pointer-events-none: the drop must land on the terminal, not on this.
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "imagesOnly"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
</div>
)}
</div>
);
}
@@ -6,6 +6,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
@@ -266,10 +267,190 @@ describe("LaneConsolePane", () => {
await waitFor(() => expect(prompt.value).toBe("add a button"));
});
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
const HISTORY_ITEM = {
id: "run-old",
session_id: "sess-old",
cwd: "/workspace/a",
model: null,
permission_mode: null,
effort: null,
resume_session_id: null,
prompt_preview: "earlier prompt",
status: "abandoned" as const,
exit_code: null,
started_at: new Date(1000).toISOString(),
ended_at: new Date(2000).toISOString(),
isLive: false,
tool: "claude" as const,
};
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
// Matches the real shape `request()` throws: a plain, human-readable
// `.message` plus a `.code` a caller actually branches on.
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
);
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
lanes: [{ ...LANE, run_id: "run-live" }],
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-live",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} />
</MemoryRouter>
);
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
const resumeButtons = await screen.findAllByRole("button", {
name: "Resume existing session",
});
fireEvent.click(resumeButtons[0]!);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
);
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
});
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
const run = (id: string, laneId: number, promptPreview: string) => ({
id,
laneId,
status: "running",
cwd: "/workspace/x",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
});
const session = (id: string, cwd: string, name: string) =>
({
id,
cwd,
name,
model: null,
source: "local",
started_at: "2026-09-24T01:00:00Z",
}) as never;
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
lanes={[
LANE,
{ ...LANE, id: 2, cwd: "/workspace/b" },
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
]}
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
externalSessions={[
session("s1", "/workspace/a/src", "my session"),
session("s2", "/workspace/b", "b session"),
session("s3", "/workspace/a/nested/x", "nested session"),
session("s4", "/workspace/ab", "sibling prefix"),
]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.getByText("my session")).toBeTruthy();
expect(screen.queryByText("other lane's")).toBeNull();
expect(screen.queryByText("b session")).toBeNull();
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
describe("split panes on one lane", () => {
const run = (id: string, promptPreview = id) => ({
id,
laneId: 1,
status: "running" as const,
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
tool: "claude" as const,
});
it("attaches a pane only to its own slot's session", async () => {
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-2"
)
);
});
it("starts into its own slot and shows the session the server opened", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "agenthub-lane-1" },
run_id: "agenthub-lane-1-1",
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
render(<LaneConsolePane {...baseProps()} slot={1} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ slot: 1 })
)
);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-1"
)
);
});
it("leaves runs other panes show out of Active runs", () => {
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
slot={1}
activeRuns={{
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
}}
hiddenRunIds={["agenthub-lane-1"]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
expect(screen.getByText("free")).toBeTruthy();
expect(screen.queryByText("shown")).toBeNull();
});
});
});
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
onAttach: vi.fn(),
onResumeFromHistory: vi.fn(),
onViewFromHistory: vi.fn(),
onKill: vi.fn(),
onRefresh: vi.fn(),
};
const utils = render(
@@ -127,6 +128,7 @@ function renderModal(
onAttach: vi.fn(),
onResume: vi.fn(),
onView: vi.fn(),
onKill: vi.fn(),
onClose: vi.fn(),
onRefresh: vi.fn(),
};
@@ -274,6 +276,19 @@ describe("RunsModal", () => {
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
});
it("needs two clicks to kill a live row, and never offers Kill for an external one", () => {
const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill"));
fireEvent.click(killButton);
expect(spies.onKill).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?")));
expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID);
unmount();
renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]);
expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull();
});
it("fires resume with the history item behind a finished row", () => {
const { spies } = renderModal([row(PAST_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
@@ -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, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
const writeMock = vi.fn();
@@ -17,6 +18,11 @@ const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropImageMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
const termState = {
@@ -26,9 +32,13 @@ const termState = {
rows: 28,
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn().mockImplementation(() => ({
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
...(ctorArgs.push(opts), {}),
options: termOptions,
get modes() {
return termState.modes;
},
@@ -56,8 +66,13 @@ vi.mock("@xterm/xterm", () => ({
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
vi.mock("../../../lib/terminalTheme", () => ({
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
@@ -103,6 +118,10 @@ describe("TerminalView", () => {
loadAddonMock.mockClear();
openMock.mockClear();
focusMock.mockClear();
disposeMock.mockClear();
ctorArgs.length = 0;
delete termOptions.theme;
localStorage.clear();
});
afterEach(cleanup);
@@ -126,6 +145,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]!;
@@ -286,4 +320,31 @@ describe("TerminalView", () => {
expect(MockWebSocket.instances).toHaveLength(1);
vi.useRealTimers();
});
it("pastes the server-side path of a dropped image into the pane", async () => {
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
const target = screen.getByTestId("terminal-view");
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
expect(dropImageMock).toHaveBeenCalledWith(img);
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("uploads nothing for a dropped non-image and says why", async () => {
dropImageMock.mockReset();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [txt] },
});
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
expect(dropImageMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance: the Dark / Light / System mode switch and
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Palette, Sun } from "lucide-react";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
"intellij-light": "IntelliJ Light",
canvas: "Canvas",
};
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "dark", Icon: Moon },
{ mode: "light", Icon: Sun },
{ mode: "system", Icon: Monitor },
];
function Swatch({ mode, name }: { mode: Theme; name: string }) {
return (
<div
aria-hidden
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
data-mode={mode}
data-theme={name === "default" ? undefined : name}
>
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-accent" />
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
</div>
<div className="font-mono text-[10px] leading-tight">
<span className="text-syntax-keyword">const</span>{" "}
<span className="text-syntax-function">x</span>{" "}
<span className="text-syntax-operator">=</span>{" "}
<span className="text-syntax-string">"ok"</span>
</div>
<div className="flex gap-1">
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
</div>
</div>
</div>
);
}
export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
return (
<section id="appearance" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Palette className="w-4 h-4 text-fg-muted" />
{t("appearance.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
<div className="card p-5 space-y-5">
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{MODES.map(({ mode: m, Icon }) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
aria-pressed={mode === m}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
mode === m
? "bg-accent/20 text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</button>
))}
</div>
</div>
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</section>
);
}
@@ -0,0 +1,60 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AppearanceSettings } from "../AppearanceSettings";
const root = document.documentElement;
describe("AppearanceSettings", () => {
beforeEach(() => {
localStorage.clear();
root.classList.remove("dark");
root.removeAttribute("data-theme");
root.removeAttribute("data-mode");
});
it("renders every theme of both modes, defaults selected", () => {
render(<AppearanceSettings />);
const pressed = screen
.getAllByRole("button", { pressed: true })
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
});
it("picking a dark theme applies it at once; a light pick waits for light mode", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /Darcula/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /Canvas/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /^Light$/ }));
expect(root.classList.contains("dark")).toBe(false);
expect(root.dataset.theme).toBe("canvas");
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
});
it("the System mode button is pressed after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /System/ }));
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
it("each swatch scopes its own mode and theme", () => {
const { container } = render(<AppearanceSettings />);
expect(container.querySelector('.dark[data-theme="darcula"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
});
});
@@ -151,7 +151,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x2", innerW)
.attr("y1", (d) => yScale(d))
.attr("y2", (d) => yScale(d))
.attr("stroke", "#2a2a3d")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1);
// Y axis (sessions)
@@ -166,7 +166,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
)
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -176,7 +176,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#9ca3af")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -185,7 +185,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", innerW / 2)
.attr("y", innerH + 38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -196,7 +196,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", -innerH / 2)
.attr("y", -38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -229,7 +229,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", bx + bw / 2)
.attr("y", by - 5)
.attr("text-anchor", "middle")
.attr("fill", "#a5b4fc")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
@@ -242,7 +242,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("y", innerH - 1)
.attr("width", bw)
.attr("height", 1)
.attr("fill", "#2a2a3d");
.attr("fill", "rgb(var(--border))");
}
// Transparent, full-height hover target on top of the bar.
@@ -364,12 +364,12 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
label={t("compaction.sessionsAffected")}
value={affected.toLocaleString()}
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
accent="text-violet-300"
accent="text-violet-700 dark:text-violet-300"
/>
<StatBox
label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)}
accent="text-blue-400"
accent="text-blue-700 dark:text-blue-400"
/>
<StatBox
label={t("compaction.peakSession")}
@@ -407,7 +407,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
{/* Hover tooltip (matches the app's other chart tooltips) */}
{tip && (
<div
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
className="fixed z-50 pointer-events-none rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-xs shadow-xl"
style={{
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
top: tip.y - 10,
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#10b981"
stroke="rgb(var(--status-success))"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
@@ -174,7 +174,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
</div>
<span
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
style={{ color: isHovered ? color : "#9ca3af" }}
style={{ color: isHovered ? color : "rgb(var(--fg-secondary))" }}
>
{d.count}
</span>
@@ -587,7 +587,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("x", (_, i) => layerXPositions[i] ?? 0)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "10px")
.attr("font-weight", "500")
.attr("letter-spacing", "0.08em")
@@ -605,7 +605,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("y1", 32)
.attr("x2", sepX)
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
.attr("stroke", "#1f1f30")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
}
@@ -651,7 +651,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
@@ -853,7 +853,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
ref={tipRef}
role="tooltip"
aria-hidden="true"
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
className="fixed z-50 px-3 py-2 bg-surface-2 border border-border rounded-lg shadow-2xl pointer-events-none"
style={{
display: "none",
opacity: 0,
@@ -922,10 +922,11 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
row.style.cssText =
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
const lbl = document.createElement("span");
lbl.style.color = "#64748b";
lbl.style.color = "rgb(var(--fg-muted))";
lbl.textContent = label;
const val = document.createElement("span");
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
val.style.cssText =
"color:rgb(var(--fg-primary));font-weight:500;font-variant-numeric:tabular-nums";
val.textContent = value;
row.appendChild(lbl);
row.appendChild(val);
@@ -935,12 +936,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
const desc = document.createElement("p");
desc.style.cssText =
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
"font-size:11px;color:rgb(var(--fg-secondary));line-height:1.45;border-top:1px solid rgb(var(--border));padding-top:8px;margin:0";
desc.textContent = meta.description;
el.appendChild(desc);
const hint = document.createElement("p");
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
hint.style.cssText = "font-size:10px;color:rgb(var(--fg-muted));line-height:1.45;margin:6px 0 0";
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
el.appendChild(hint);
}
@@ -115,7 +115,7 @@ function Tooltip({ state }: { state: TooltipState }) {
return (
<div
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
className="fixed z-50 px-3 py-2 text-xs bg-surface-2 border border-border rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? state.x - 12 : state.x + 12,
top: state.y - 10,
@@ -144,7 +144,7 @@ function Tooltip({ state }: { state: TooltipState }) {
</span>
)}
</div>
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
<div className="mt-1 pt-1 border-t border-border">
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
</span>
@@ -262,7 +262,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
// Grid lines
const gridColor = "#2a2a3d";
const gridColor = "rgb(var(--border))";
g.append("g")
.attr("class", "grid-x")
@@ -303,9 +303,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.tickFormat((d) => fmtXTick(d as number))
)
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// X axis label
@@ -313,7 +313,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", innerW / 2)
.attr("y", innerH + 44)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.duration"));
@@ -321,9 +321,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
g.append("g")
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// Y axis label
@@ -332,7 +332,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", -innerH / 2)
.attr("y", -40)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.agentCount"));
@@ -75,7 +75,7 @@ type AgentNode = SessionDrillInData["tree"][number];
function statusColor(status: string): string {
switch (status) {
case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
return "text-violet-700 dark:text-violet-400 bg-violet-500/10 border-violet-500/20";
case "working":
return "text-status-success bg-status-success/10 border-status-success/20";
case "error":
@@ -83,7 +83,7 @@ function statusColor(status: string): string {
case "active":
return "text-status-success bg-status-success/10 border-status-success/20";
case "waiting":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
return "text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
default:
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
}
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
return (
<div>
<div
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
>
{/* Depth connector line */}
@@ -186,7 +186,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
{/* Name */}
<span
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
className={`text-sm font-medium truncate ${isMain ? "text-indigo-700 dark:text-indigo-300" : "text-fg-secondary"}`}
>
{node.name}
</span>
@@ -252,10 +252,10 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{events.map((ev) => (
<div
key={ev.id}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
>
{/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
{ev.tool_name ?? ev.event_type}
</span>
@@ -286,8 +286,8 @@ interface EventSequenceProps {
const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-500",
tool_result: "text-status-success",
agent_start: "text-indigo-400",
agent_stop: "text-violet-400",
agent_start: "text-indigo-700 dark:text-indigo-400",
agent_stop: "text-violet-700 dark:text-violet-400",
compaction: "text-status-warning",
error: "text-status-danger",
};
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
{recent.map((ev) => (
<div
key={ev.id}
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
>
{/* Event type badge */}
<span
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
key={s.id}
type="button"
onClick={() => handleSelect(s.id)}
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
>
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button"
onClick={handleLoadMore}
disabled={loading}
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
>
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button>
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.close")}
>
<X className="w-3.5 h-3.5" />
@@ -121,13 +121,13 @@ function statusClass(status: string): string {
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
// as its own color in both the filter row and the result label chips.
const PHASE_PALETTE = [
"bg-violet-500/15 text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-300 border-sky-500/40",
"bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/40",
"bg-status-warning/15 text-status-warning border-status-warning/40",
"bg-status-success/15 text-status-success border-status-success/40",
"bg-rose-500/15 text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
"bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/40",
];
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
@@ -321,7 +321,7 @@ export function WorkflowRunsPanel({
if (!controlled && loading) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
<Loader2 className="w-4 h-4 animate-spin text-violet-700 dark:text-violet-400" />
<span className="animate-pulse">{t("runs.loading")}</span>
</div>
);
@@ -365,7 +365,7 @@ export function WorkflowRunsPanel({
{running ? (
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
) : (
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@@ -393,7 +393,7 @@ export function WorkflowRunsPanel({
<Link
to={`/sessions/${encodeURIComponent(run.session_id)}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
className="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
title={t("runs.openSession")}
>
<ExternalLink className="w-3.5 h-3.5" />
@@ -565,7 +565,7 @@ export function WorkflowRunsPanel({
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
{ts?.loading && (
<span className="flex items-center gap-1 text-violet-400">
<span className="flex items-center gap-1 text-violet-700 dark:text-violet-400">
<Loader2 className="w-3 h-3 animate-spin" />
{t("runs.loadingFull")}
</span>
@@ -71,7 +71,7 @@ function formatDurationSec(sec: number): string {
function successRateColor(rate: number): string {
if (rate > 90) return "text-status-success";
if (rate > 70) return "text-yellow-400";
if (rate > 70) return "text-yellow-700 dark:text-yellow-400";
return "text-status-danger";
}
@@ -230,10 +230,10 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-border">
<span className="text-base font-semibold text-fg-primary tabular-nums">
{valueDisplay}
</span>
@@ -325,7 +325,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgAgentDepth")}
value={stats.avgDepth.toFixed(1)}
icon={GitFork}
accentClass="text-indigo-400"
accentClass="text-indigo-700 dark:text-indigo-400"
calculationKey="stats.tooltip.calc.depth"
interp={interpAvgDepth(stats.avgDepth)}
metricPhraseKey="stats.tooltip.phrase.depth"
@@ -352,7 +352,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.mostCommonFlow")}
value={topFlowLabel}
icon={ArrowRightLeft}
accentClass="text-violet-400"
accentClass="text-violet-700 dark:text-violet-400"
calculationKey="stats.tooltip.calc.topFlow"
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
metricPhraseKey="stats.tooltip.phrase.topFlow"
@@ -361,7 +361,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgCompactions")}
value={stats.avgCompactions.toFixed(1)}
icon={Layers}
accentClass="text-cyan-400"
accentClass="text-cyan-700 dark:text-cyan-400"
calculationKey="stats.tooltip.calc.compactions"
interp={interpAvgCompactions(stats.avgCompactions)}
metricPhraseKey="stats.tooltip.phrase.compactions"
+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,
};
}
+110 -8
View File
@@ -22,13 +22,20 @@
"add": "Add lane",
"addLane": "Create a lane from a working directory",
"addLaneBaseLabel": "Branch to fork from",
"addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.",
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
"addLaneBranchExistingLabel": "Branch to check out",
"addLaneBranchHintAuto": "Leave empty to let the server name the branch from the title.",
"addLaneBranchLabel": "New branch name",
"addLaneBranchPlaceholder": "feat/my-feature",
"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": "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.",
"addLanePipelineHint": "Which stages this lane's map shows. Pick the one the skill driving it declares against — nothing else in this UI changes it later.",
"addLanePipelineLabel": "Pipeline template",
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
@@ -52,11 +59,68 @@
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"autoStage": "auto: {{stage}}",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
"browse.button": "Browse",
"browse.empty": "This folder is empty.",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"changes.back": "All files",
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
"changes.collapse": "Collapse the changes panel",
"changes.collapseAll": "Collapse every file",
"changes.collapseAllFolders": "Collapse all folders",
"changes.collapseContext": "Show fewer surrounding lines",
"changes.collapseFile": "Collapse {{path}}",
"changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged",
"changes.conflicted": "conflict",
"changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone",
"changes.empty.branch": "This branch matches its base.",
"changes.empty.commit": "This commit changed no files.",
"changes.empty.work": "Nothing uncommitted.",
"changes.expand": "Show the changes panel",
"changes.expandAll": "Expand every file",
"changes.expandAllFolders": "Expand all folders",
"changes.expandContext": "Show more surrounding lines",
"changes.expandFile": "Expand {{path}}",
"changes.fileGone": "That file no longer has changes in this range.",
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
"changes.layout.split": "Side-by-side",
"changes.layout.unified": "Unified",
"changes.loading": "Reading the working copy…",
"changes.mode.browse": "Files",
"changes.mode.history": "History",
"changes.mode.overview": "Overview",
"changes.mode.review": "Review",
"changes.noCommits": "No commits yet.",
"changes.noHunks": "No textual change.",
"changes.noMatches": "No tracked file matches.",
"changes.openFile": "Open {{path}}",
"changes.quickOpen": "Find a file by path…",
"changes.range.branch": "vs base",
"changes.range.branchHint": "base...HEAD — everything this lane's branch adds on top of its base",
"changes.range.work": "Uncommitted",
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
"changes.refresh": "Refresh",
"changes.resize": "Resize the changes panel (next: {{size}})",
"changes.size.compact": "Narrow",
"changes.size.half": "Medium",
"changes.size.full": "Full screen",
"changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage all ({{count}})",
"changes.dropEdits": "Discard your unsaved edits to this file?",
"changes.stagedGroup": "Staged Changes",
"changes.unstagedGroup": "Changes",
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This lane's directory is not a readable git repository.",
"changes.unstage": "Unstage this file",
"changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
"confirmRemoveMessage": "This action cannot be undone.",
@@ -94,7 +158,7 @@
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
"destructive.reset.title": "Reset managed worktree?",
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
"empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal:",
"empty.body": "A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
"empty.title": "No lanes yet",
"features.archived": "archived",
"features.live": "Live",
@@ -105,6 +169,29 @@
"git.filesMore_other": "… and {{count}} more files",
"git.noDelta": "new",
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
"gitBar.branchCreate": "Create",
"gitBar.branchCreateHint": "Create this branch without switching to it",
"gitBar.branchDelete": "Delete",
"gitBar.branchDeleteHint": "Delete the selected branch (refused if it has unmerged commits)",
"gitBar.branchNewPlaceholder": "New branch name…",
"gitBar.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",
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
"gitBar.push": "Push",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.switchCurrent": "current",
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
"gitBar.switchHint": "Switch this worktree to another local branch",
"gitBar.switchLabel": "Switch branch…",
"kind.adopted": "adopted",
"kind.managed": "managed",
"laneDetail.hide": "Hide details",
@@ -142,7 +229,6 @@
"splitView.paneCount": "{{count}} pane",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.pickLane": "Pick a lane",
"stageUndeclared": "not declared",
"status.failed": "failed",
"status.idle": "idle",
"status.provisioning": "provisioning",
@@ -151,5 +237,21 @@
"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",
"changes.stageHunkShort": "Keep",
"changes.undoHunkShort": "Undo",
"changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit",
"changes.save": "Save",
"changes.cancelEdit": "Cancel",
"changes.editorLabel": "Edit {{path}}",
"changes.prevHunk": "Previous change",
"changes.nextHunk": "Next change",
"changes.hunkOf": "{{n}} of {{total}}",
"changes.noEditBinary": "Binary file — not editable here",
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
"changes.noEdit": "Not editable here"
}
+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",
+9 -1
View File
@@ -34,6 +34,7 @@
},
"errors": {
"attachFailed": "Couldn't attach to run: {{message}}",
"attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
"cwdRequired": "Working directory required to start a run",
"killFailed": "Couldn't stop: {{message}}",
"laneCreateFailed": "Failed to create lane: {{message}}",
@@ -61,6 +62,7 @@
"cwdGroups": {
"dashboard": "Dashboard server",
"home": "Home",
"repo": "Source repos",
"recent": "Recent - used by Claude Code"
},
"cwdHint": "Absolute path. Defaults to the dashboard's own cwd.",
@@ -96,7 +98,7 @@
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter to send"
"shortcut": "Enter to send · Shift+Enter for newline"
},
"limitations": {
"dismiss": "Got it"
@@ -162,5 +164,11 @@
"input": "Input",
"label": "Context",
"output": "Output"
},
"drop": {
"hint": "Drop image to attach",
"uploading": "Uploading image…",
"failed": "Could not attach the image",
"imagesOnly": "Only images can be dropped here"
}
}
+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"
}
}
+110 -8
View File
@@ -22,13 +22,20 @@
"add": "Thêm lane",
"addLane": "Tạo lane từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới",
"addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.",
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
"addLaneBranchExistingLabel": "Nhánh cần checkout",
"addLaneBranchHintAuto": "Để trống để server tự đặt tên nhánh theo tiêu đề.",
"addLaneBranchLabel": "Tên nhánh mới",
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
"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": "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).",
"addLanePipelineHint": "Bản đồ lane này sẽ hiện những stage nào. Chọn đúng cái mà skill điều khiển nó khai báo — sau này không màn hình nào đổi được.",
"addLanePipelineLabel": "Mẫu pipeline",
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
@@ -52,11 +59,68 @@
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"autoStage": "tự động: {{stage}}",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
"browse.button": "Duyệt",
"browse.empty": "Thư mục này trống.",
"browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục",
"changes.back": "Tất cả file",
"changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
"changes.collapse": "Thu gọn bảng thay đổi",
"changes.collapseAll": "Thu gọn tất cả file",
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
"changes.collapseContext": "Bớt dòng xung quanh",
"changes.collapseFile": "Thu gọn {{path}}",
"changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage",
"changes.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
"changes.empty.branch": "Nhánh này không khác nhánh gốc.",
"changes.empty.commit": "Commit này không đổi file nào.",
"changes.empty.work": "Không có gì chưa commit.",
"changes.expand": "Mở bảng thay đổi",
"changes.expandAll": "Mở tất cả file",
"changes.expandAllFolders": "Mở tất cả thư mục",
"changes.expandContext": "Hiện thêm dòng xung quanh",
"changes.expandFile": "Mở {{path}}",
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
"changes.layout.split": "Hai cột",
"changes.layout.unified": "Một cột",
"changes.loading": "Đang đọc working copy…",
"changes.mode.browse": "Tệp",
"changes.mode.history": "Lịch sử",
"changes.mode.overview": "Tổng quan",
"changes.mode.review": "Xem toàn bộ",
"changes.noCommits": "Chưa có commit nào.",
"changes.noHunks": "Không có thay đổi nội dung.",
"changes.noMatches": "Không có file nào khớp.",
"changes.openFile": "Mở {{path}}",
"changes.quickOpen": "Tìm file theo đường dẫn…",
"changes.range.branch": "So nhánh gốc",
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của lane này thêm vào so với nhánh gốc",
"changes.range.work": "Chưa commit",
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
"changes.refresh": "Tải lại",
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
"changes.size.compact": "Hẹp",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình",
"changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
"changes.stagedGroup": "Đã stage",
"changes.unstagedGroup": "Thay đổi",
"changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của lane này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index",
"changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
@@ -94,7 +158,7 @@
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
"destructive.reset.title": "Đặt lại worktree được quản lý?",
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
"empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Thêm ở đây, hoặc từ terminal:",
"empty.body": "Mỗi lane là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
"empty.title": "Chưa có lane nào",
"features.archived": "đã lưu trữ",
"features.live": "Phiên bản trực tiếp",
@@ -105,6 +169,29 @@
"git.filesMore_other": "… và {{count}} tệp nữa",
"git.noDelta": "mới",
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
"gitBar.branchCreate": "Tạo",
"gitBar.branchCreateHint": "Tạo nhánh này mà không chuyển sang",
"gitBar.branchDelete": "Xoá",
"gitBar.branchDeleteHint": "Xoá nhánh đang chọn (git từ chối nếu còn commit chưa merge)",
"gitBar.branchNewPlaceholder": "Tên nhánh mới…",
"gitBar.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",
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
"gitBar.push": "Push",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.switchCurrent": "hiện tại",
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
"gitBar.switchLabel": "Đổi nhánh…",
"kind.adopted": "đã nhận",
"kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
@@ -142,7 +229,6 @@
"splitView.paneCount": "{{count}} ô",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.pickLane": "Chọn lane",
"stageUndeclared": "chưa khai báo",
"status.failed": "thất bại",
"status.idle": "rảnh",
"status.provisioning": "đang khởi tạo",
@@ -151,5 +237,21 @@
"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",
"changes.stageHunkShort": "Giữ",
"changes.undoHunkShort": "Hoàn tác",
"changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa",
"changes.save": "Lưu",
"changes.cancelEdit": "Huỷ",
"changes.editorLabel": "Sửa {{path}}",
"changes.prevHunk": "Thay đổi trước",
"changes.nextHunk": "Thay đổi sau",
"changes.hunkOf": "{{n}} / {{total}}",
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
"changes.noEdit": "Không sửa được ở đây"
}
+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",
+9 -1
View File
@@ -33,6 +33,7 @@
},
"errors": {
"attachFailed": "Không gắn được vào run: {{message}}",
"attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
"cwdRequired": "Cần thư mục làm việc để bắt đầu run",
"killFailed": "Không dừng được: {{message}}",
"laneCreateFailed": "Không thể tạo làn đường: {{message}}",
@@ -60,6 +61,7 @@
"cwdGroups": {
"dashboard": "Dashboard server",
"home": "Home",
"repo": "Repo nguồn",
"recent": "Gần đây - Claude Code đã dùng"
},
"cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.",
@@ -95,7 +97,7 @@
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
},
"limitations": {
"dismiss": "Đã rõ"
@@ -161,5 +163,11 @@
"input": "Input",
"label": "Context",
"output": "Output"
},
"drop": {
"hint": "Thả ảnh để đính kèm",
"uploading": "Đang tải ảnh lên…",
"failed": "Không đính kèm được ảnh",
"imagesOnly": "Chỉ thả được ảnh vào đây"
}
}
+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"
}
}
+261 -24
View File
@@ -15,35 +15,86 @@
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
* 3-5 component background 9-10 solid (buttons, vibrant fills)
* 11-12 text (11 secondary, 12 primary)
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
* and terracotta, because a cool near-white page reads as glare. `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
*/
:root {
:root,
[data-mode="light"] {
/* Light theme — the CSS default, so it applies with no class on <html>.
Radix `slate` (light), steps 1/2/3/4/5/6/7. */
--surface-0: 252 252 253; /* slate-1 #fcfcfd */
--surface-1: 249 249 251; /* slate-2 #f9f9fb */
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */
--surface-3: 232 232 236; /* slate-4 #e8e8ec */
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
--border: 217 217 224; /* slate-6 #d9d9e0 */
--border-light: 205 206 214; /* slate-7 #cdced6 */
--accent: 0 144 255; /* blue-9 #0090ff */
--accent-hover: 5 136 240; /* blue-10 #0588f0 */
--fg-primary: 28 32 36; /* slate-12 #1c2024 */
--fg-secondary: 96 100 108; /* slate-11 #60646c */
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
/* Status: green/red/amber step 11 (light) — the same "readable text" step
as fg-secondary, so success/danger/warning sit at the same visual weight
as ordinary secondary text, not shouting over it. */
--status-success: 33 131 88; /* green-11 #218358 */
--status-danger: 206 44 49; /* red-11 #ce2c31 */
--status-warning: 171 100 0; /* amber-11 #ab6400 */
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
page (the Settings appearance cards).
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
as it does in dark: the page is the DARKEST step and a card is brighter
than the page it sits on. The previous light block used the scale in its
native direction, which inverted the whole language — a near-white page
(glare) under grey cards that read as recessed, and every `hover:bg-*`
darkening instead of lifting.
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
A cool near-white ramp kept reading as glare no matter how far the page
step was pulled down, because the brightest thing on screen was also the
bluest. Cream lowers the perceived glare at the same measured lightness
and gives every neutral a warm cast to sit against. The whole ramp sits
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
white: at full brightness a large light panel still tires the eye. */
--surface-0: 232 229 218; /* #e8e5da — page background */
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
--border: 207 201 184; /* #cfc9b8 */
--border-light: 222 217 204; /* #ded9cc */
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer. */
--accent: 163 74 40; /* #a34a28 */
--accent-hover: 142 63 32; /* #8e3f20 */
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
sits a clear step below fg-secondary. */
--fg-muted: 104 99 89; /* #686359 */
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
over cream. The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
the card; these also clear it against the status's OWN densest chip (a
20% tint of itself — 4.5:1), which is where the label actually sits. */
--status-success: 22 104 66; /* #166842 */
--status-danger: 170 36 41; /* #aa2429 */
--status-warning: 133 79 0; /* #854f00 */
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
--tint-base: var(--surface-3);
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 4 120 87; /* emerald-700 #047857 */
--syn-number: 194 65 12; /* orange-700 #c2410c */
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
--syn-function: 180 83 9; /* amber-700 #b45309 */
--syn-operator: 190 24 93; /* pink-700 #be185d */
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
}
.dark {
@@ -70,6 +121,187 @@
--status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
--tint-base: var(--surface-3);
/* Unchanged from the pastels this file has always drawn code in. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
--syn-number: 253 186 116; /* orange-300 #fdba74 */
--syn-keyword: 196 181 253; /* violet-300 #c4b5fd */
--syn-builtin: 125 211 252; /* sky-300 #7dd3fc */
--syn-function: 254 240 138; /* yellow-200 #fef08a */
--syn-operator: 249 168 212; /* pink-300 #f9a8d4 */
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
--syn-tag: 253 164 175; /* rose-300 #fda4af */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
--syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */
}
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
* Settings → Appearance). Each overrides only what differs from its mode's
* default block above; `--tint-base` and light `--syn-plain` are derived there.
* Dark ones pair with `.dark`, light ones with `[data-mode="light"]`, so a
* theme can never paint over the wrong mode whatever order the attributes
* land in. Contrast is held to the defaults' own bar: fg-muted ≥4.5:1 on a
* light card, status labels ≥4.5:1 on their own /20 chip in light themes. */
.dark[data-theme="darcula"] {
--surface-0: 30 31 32; /* #1e1f20 */
--surface-1: 35 36 37; /* #232425 */
--surface-2: 39 41 42; /* #27292a */
--surface-3: 43 43 43; /* #2b2b2b */
--surface-4: 51 53 55; /* #333537 */
--surface-5: 60 63 65; /* #3c3f41 */
--border: 63 66 68; /* #3f4244 */
--border-light: 77 81 83; /* #4d5153 */
--accent: 53 146 196; /* #3592c4 */
--accent-hover: 74 163 214; /* #4aa3d6 */
--fg-primary: 220 220 220; /* #dcdcdc */
--fg-secondary: 169 183 198; /* #a9b7c6 */
--fg-muted: 140 146 150; /* #8c9296 */
--status-success: 126 194 135; /* #7ec287 */
--status-danger: 255 143 140; /* #ff8f8c */
--status-warning: 232 191 106; /* #e8bf6a */
--syn-comment: 128 128 128; /* #808080 */
--syn-string: 127 159 105; /* #7f9f69 */
--syn-number: 104 151 187; /* #6897bb */
--syn-keyword: 204 120 50; /* #cc7832 */
--syn-builtin: 136 136 198; /* #8888c6 */
--syn-function: 255 198 109; /* #ffc66d */
--syn-operator: 169 183 198; /* #a9b7c6 */
--syn-punctuation: 169 183 198; /* #a9b7c6 */
--syn-property: 168 139 192; /* #a88bc0 */
--syn-tag: 232 191 106; /* #e8bf6a */
--syn-add: 106 171 115; /* #6aab73 */
--syn-del: 255 107 104; /* #ff6b68 */
--syn-plain: 169 183 198; /* #a9b7c6 */
}
.dark[data-theme="apple-dark"] {
--surface-0: 22 22 24; /* #161618 */
--surface-1: 28 28 30; /* #1c1c1e */
--surface-2: 36 36 38; /* #242426 */
--surface-3: 44 44 46; /* #2c2c2e */
--surface-4: 54 54 56; /* #363638 */
--surface-5: 58 58 60; /* #3a3a3c */
--border: 56 56 58; /* #38383a */
--border-light: 72 72 74; /* #48484a */
--accent: 10 132 255; /* #0a84ff */
--accent-hover: 64 156 255; /* #409cff */
--fg-primary: 245 245 247; /* #f5f5f7 */
--fg-secondary: 174 174 178; /* #aeaeb2 */
--fg-muted: 142 142 147; /* #8e8e93 */
--status-success: 48 209 88; /* #30d158 */
--status-danger: 255 140 134; /* #ff8c86 */
--status-warning: 255 214 10; /* #ffd60a */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
--syn-number: 208 191 105; /* #d0bf69 */
--syn-keyword: 252 95 163; /* #fc5fa3 */
--syn-builtin: 93 216 255; /* #5dd8ff */
--syn-function: 103 183 164; /* #67b7a4 */
--syn-operator: 161 161 166; /* #a1a1a6 */
--syn-punctuation: 161 161 166; /* #a1a1a6 */
--syn-property: 181 140 240; /* #b58cf0 */
--syn-tag: 252 95 163; /* #fc5fa3 */
--syn-add: 48 209 88; /* #30d158 */
--syn-del: 255 105 97; /* #ff6961 */
--syn-plain: 229 229 234; /* #e5e5ea */
}
[data-mode="light"][data-theme="apple-light"] {
--surface-0: 233 233 238; /* #e9e9ee */
--surface-1: 239 239 244; /* #efeff4 */
--surface-2: 245 245 248; /* #f5f5f8 */
--surface-3: 251 251 253; /* #fbfbfd */
--surface-4: 253 253 254; /* #fdfdfe */
--surface-5: 255 255 255; /* #ffffff */
--border: 209 209 214; /* #d1d1d6 */
--border-light: 229 229 234; /* #e5e5ea */
--accent: 0 102 204; /* #0066cc */
--accent-hover: 0 85 179; /* #0055b3 */
--fg-primary: 29 29 31; /* #1d1d1f */
--fg-secondary: 81 81 84; /* #515154 */
--fg-muted: 110 110 115; /* #6e6e73 */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
--status-success: 23 107 46; /* #176b2e */
--status-danger: 179 0 14; /* #b3000e */
--status-warning: 122 79 0; /* #7a4f00 */
--syn-comment: 93 108 121; /* #5d6c79 */
--syn-string: 196 26 22; /* #c41a16 */
--syn-number: 28 0 207; /* #1c00cf */
--syn-keyword: 155 35 147; /* #9b2393 */
--syn-builtin: 11 79 121; /* #0b4f79 */
--syn-function: 50 109 116; /* #326d74 */
--syn-operator: 75 75 80; /* #4b4b50 */
--syn-punctuation: 75 75 80; /* #4b4b50 */
--syn-property: 57 0 160; /* #3900a0 */
--syn-tag: 155 35 147; /* #9b2393 */
--syn-add: 31 122 54; /* #1f7a36 */
--syn-del: 197 0 15; /* #c5000f */
}
[data-mode="light"][data-theme="intellij-light"] {
--surface-0: 236 238 242; /* #eceef2 */
--surface-1: 241 242 245; /* #f1f2f5 */
--surface-2: 247 248 250; /* #f7f8fa */
--surface-3: 253 253 254; /* #fdfdfe */
--surface-4: 255 255 255; /* #ffffff */
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 47 106 227; /* #2f6ae3 */
--accent-hover: 39 88 192; /* #2758c0 */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 108 112 126; /* #6c707e */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
}
[data-mode="light"][data-theme="canvas"] {
--surface-0: 227 220 205; /* #e3dccd */
--surface-1: 232 226 212; /* #e8e2d4 */
--surface-2: 237 232 220; /* #ede8dc */
--surface-3: 243 239 230; /* #f3efe6 */
--surface-4: 247 244 237; /* #f7f4ed */
--surface-5: 250 248 243; /* #faf8f3 */
--border: 203 191 166; /* #cbbfa6 */
--border-light: 220 211 192; /* #dcd3c0 */
--accent: 122 82 48; /* #7a5230 */
--accent-hover: 102 63 34; /* #663f22 */
--fg-primary: 43 38 32; /* #2b2620 */
--fg-secondary: 94 85 74; /* #5e554a */
--fg-muted: 107 97 85; /* #6b6155 */
--status-success: 54 93 36; /* #365d24 */
--status-danger: 147 41 31; /* #93291f */
--status-warning: 109 76 0; /* #6d4c00 */
--syn-comment: 125 114 98; /* #7d7262 */
--syn-string: 77 107 31; /* #4d6b1f */
--syn-number: 154 91 0; /* #9a5b00 */
--syn-keyword: 138 59 18; /* #8a3b12 */
--syn-builtin: 47 94 110; /* #2f5e6e */
--syn-function: 122 90 0; /* #7a5a00 */
--syn-operator: 138 48 80; /* #8a3050 */
--syn-punctuation: 107 97 85; /* #6b6155 */
--syn-property: 59 102 96; /* #3b6660 */
--syn-tag: 142 47 47; /* #8e2f2f */
--syn-add: 77 107 31; /* #4d6b1f */
--syn-del: 159 47 35; /* #9f2f23 */
}
@layer base {
@@ -102,12 +334,17 @@
}
::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
/* The browser draws this glyph near-black. Inverting it is a DARK-mode fix;
applied globally it painted a white icon onto a white field. */
.dark ::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
}
::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
@@ -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();
}
});
});
+213 -13
View File
@@ -396,10 +396,16 @@ import type {
CostResult,
DashboardEvent,
Lane,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneFileContent,
LaneTreeEntry,
LaneCounts,
LaneGitFacts,
LaneRuntime,
LaneTool,
LaneWorktree,
ModelPricing,
NamedLock,
PipelineStats,
@@ -476,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
@@ -500,7 +515,11 @@ export function dashboardToken(): string | null {
* @returns The parsed JSON response body, typed as `T`.
* @throws {Error} When the response status is not ok (non-2xx). The thrown
* message is `body.error.message` if the error body parsed as JSON and carried
* one, otherwise the literal `HTTP <status>`.
* one, otherwise the literal `HTTP <status>`. The server's own `error.code`
* (e.g. `"ERUNLIVE"`) rides along as `.code` on the thrown error — every
* route's error body carries one (see `server/routes/*.js`), and a caller
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = dashboardToken();
@@ -517,7 +536,10 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `HTTP ${res.status}`);
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
});
}
return res.json();
}
@@ -944,7 +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;
@@ -1370,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.
@@ -1384,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.
@@ -1951,6 +1982,14 @@ export const api = {
if (q) qs.set("q", q);
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
},
/** POST /api/run/drop-image - save an image dropped on the terminal on the
* server's machine; the returned path is what gets pasted into the pane. */
dropImage: (file: Blob) =>
request<{ path: string }>("/run/drop-image", {
method: "POST",
body: file,
headers: { "Content-Type": file.type },
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
@@ -2277,7 +2316,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),
@@ -2304,11 +2350,13 @@ export const api = {
/**
* GET /api/lanes/branches — a candidate source repo's local branches.
* @param repo Absolute path to an existing git repository.
* @returns `{ branches, current }` — local branch names (not `origin/*`)
* and which one is currently checked out (null if detached).
* @returns `{ branches, current, worktrees }` — local branch names (not
* `origin/*`), which one is currently checked out (null if detached),
* and the repo's existing worktrees, so the picker can grey out the
* branches git would refuse to check out a second time.
*/
branches: (repo: string) =>
request<{ branches: string[]; current: string | null }>(
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
`/lanes/branches?repo=${encodeURIComponent(repo)}`
),
/**
@@ -2357,6 +2405,154 @@ export const api = {
* readable git repository, which is a normal state rather than an error.
*/
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
/**
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
* the Changes panel asks for.
* @param id The lane id.
* @param opts `range` picks the comparison (`work` = uncommitted vs HEAD,
* `branch` = base...HEAD). Pass `path` for one file's hunks, or
* `full: true` for every file's; with neither it is a summary.
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
*/
changes: (
id: number,
opts: {
range?: LaneChangeRange;
path?: string;
full?: boolean;
sha?: string;
context?: number;
} = {}
) => {
const q = new URLSearchParams();
if (opts.range) q.set("range", opts.range);
if (opts.path) q.set("path", opts.path);
if (opts.full) q.set("full", "1");
if (opts.sha) q.set("sha", opts.sha);
if (opts.context != null) q.set("context", String(opts.context));
return request<LaneChanges>(`/lanes/${id}/git/changes?${q.toString()}`);
},
/**
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
* @param id The lane id.
* @param opts `file` narrows the list to the commits touching one path.
*/
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
return request<{ available: boolean; commits: LaneCommit[] }>(
`/lanes/${id}/git/log?${q.toString()}`
);
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
* @param id The lane id.
* @param path Repo-relative path.
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
*/
gitFile: (id: number, path: string, rev = "work") =>
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
* disk since answers 409 ESTALE rather than being overwritten.
*/
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(`/lanes/${id}/git/file`, {
method: "PUT",
body: JSON.stringify({ path, content, baseSha }),
}),
/**
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
* quick-open. Ignored files never appear (it reads `git ls-files`).
* @param id The lane id.
* @param q The substring typed into the quick-open box.
*/
gitLs: (id: number, q: string) =>
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
`/lanes/${id}/git/ls?q=${encodeURIComponent(q)}`
),
/**
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
* @param id The lane id.
* @param path Repo-relative directory; "" for the repo root.
*/
/**
* 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;
path: string;
entries: LaneTreeEntry[];
truncated: boolean;
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`),
/**
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
* the branch switcher. The repo comes from the lane row, never the query.
* @param id The lane id.
*/
gitBranches: (id: number) =>
request<{
available: boolean;
branches: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(`/lanes/${id}/git/branches`),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
* `branch-delete`.
* @param body `{message, only}` for commit; `{branch, create}` for switch;
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
* `{paths, confirm: true}` for discard — which is irreversible, so the
* server refuses it without that flag.
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
*/
gitAction: (
id: number,
action:
| "fetch"
| "pull"
| "push"
| "commit"
| "switch"
| "stage"
| "unstage"
| "discard"
| "stage-hunk"
| "discard-hunk"
| "branch-create"
| "branch-delete",
body: {
message?: string;
only?: "staged";
branch?: string;
base?: string;
create?: boolean;
paths?: string[];
path?: string;
hunk?: string;
context?: number;
confirm?: boolean;
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string }>(
`/lanes/${id}/git/${action}`,
{ method: "POST", body: JSON.stringify(body) }
),
/**
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
* ports, service liveness, and the last boot error.
@@ -2433,7 +2629,9 @@ export const api = {
* @returns `{ lane, ok }` — the updated lane (if applicable) or a boolean result.
*/
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
request<{ lane?: Lane; ok?: boolean }>(`/lanes/${id}/${action}`, {
// `run_id` answers a `start`: the session it opened, which for a split
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3426,6 +3624,8 @@ export interface RunStartArgs {
export interface RunHandle {
id: string;
laneId: number | null;
/** Which Workspace split pane's session this is: 0 is the lane's own. */
slot?: number | null;
status: RunStatus;
cwd: string | null;
model: string | null;
@@ -3476,7 +3676,7 @@ export interface DashboardRunHistoryItem {
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
* = previously used for a run. */
kind: "dashboard" | "home" | "recent";
kind: "dashboard" | "home" | "recent" | "repo";
path: string;
label: string;
}
+22 -19
View File
@@ -1033,46 +1033,49 @@ export function highlight(source: string, lang: string): Token[] {
/**
* Map a token type to a Tailwind colour class for rendering.
* @param type A {@link TokenType} produced by {@link highlight}.
* @returns Tailwind utility classes (text colour, and a background tint for
* diff add/remove lines); falls back to a neutral gray for "plain"/unknown.
* @returns Tailwind utility classes built on the `syntax.*` THEME TOKENS
* (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
* pastel one this file has always used, and light mode takes the same hues
* four steps darker, because a pastel keyword on a light code block is
* unreadable. Diff rows take a /20 status tint, pre-mixed per theme.
*/
export function tokenClass(type: TokenType): string {
switch (type) {
case "comment":
return "text-gray-500 italic"; // muted + italic to recede visually
return "text-syntax-comment italic"; // muted + italic to recede visually
case "string":
return "text-emerald-300"; // green, shared with diff-add text
return "text-syntax-string"; // green, shared with diff-add text
case "number":
return "text-orange-300"; // orange, shared with boolean literals
return "text-syntax-number"; // orange, shared with boolean literals
case "keyword":
return "text-violet-300"; // violet, shared with diff-meta headers
return "text-syntax-keyword"; // violet, shared with diff-meta headers
case "builtin":
return "text-sky-300";
return "text-syntax-builtin";
case "function":
return "text-yellow-200"; // yellow, shared with HTML attr names
return "text-syntax-function"; // yellow, shared with HTML attr names
case "operator":
return "text-pink-300";
return "text-syntax-operator";
case "punctuation":
return "text-gray-400"; // slightly brighter than comments, dimmer than plain
return "text-syntax-punctuation"; // brighter than comments, dimmer than plain
case "property":
return "text-cyan-300";
return "text-syntax-property";
case "tag":
return "text-rose-300";
return "text-syntax-tag";
case "attr":
return "text-yellow-200"; // matches "function" colour by design
return "text-syntax-function"; // matches "function" colour by design
case "variable":
return "text-amber-300";
return "text-syntax-function"; // amber family, same as functions/attrs
case "boolean":
return "text-orange-300"; // literals grouped with numbers by colour
return "text-syntax-number"; // literals grouped with numbers by colour
case "diff-add":
return "text-emerald-300 bg-emerald-500/10"; // green text + faint green row tint
return "text-syntax-add bg-status-success/20"; // green text + theme-aware green row tint
case "diff-del":
return "text-red-300 bg-red-500/10"; // red text + faint red row tint
return "text-syntax-del bg-status-danger/20"; // red text + theme-aware red row tint
case "diff-meta":
return "text-violet-300";
return "text-syntax-keyword";
case "plain":
default:
return "text-gray-200"; // default body text colour
return "text-syntax-plain"; // default body text colour
}
}
+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();
}
}
+129 -9
View File
@@ -2594,6 +2594,15 @@ export type LaneGitFacts =
untracked: number;
/** The files behind the counts, capped; the counts themselves stay exact. */
files: LaneGitFile[];
/** Whether the repo has any remote configured at all. */
hasRemote: boolean;
/** `origin/<branch>`, or null when this branch has never been pushed. */
upstream: string | null;
/** Commits HEAD has that the upstream does not; null without an upstream
* — not 0, which would claim "in sync" about a comparison never made. */
ahead: number | null;
/** Commits the upstream has that HEAD does not; null without an upstream. */
behind: number | null;
}
| { available: false };
@@ -2609,6 +2618,117 @@ export interface LaneGitFile {
deleted: number | null;
}
/** One entry of `git worktree list` for a repo. */
export interface LaneWorktree {
/** Absolute path of the worktree's directory. */
path: string;
/** The branch checked out there; absent for a detached worktree. */
branch?: string;
locked?: boolean;
}
/** Which comparison the Changes panel is showing. */
export type LaneChangeRange = "work" | "branch" | "commit";
/** One row of `GET /api/lanes/:id/git/log`. */
export interface LaneCommit {
sha: string;
short: string;
subject: string;
author: string;
/** ISO 8601 author date. */
date: string;
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
refs: string[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
export type LaneFileContent =
| {
available: true;
path: string;
rev: string;
binary: boolean;
truncated: boolean;
lines: string[];
/** Version stamp of a whole, textual `work` file — what a save sends
* back as `baseSha`. Absent at a commit, or when binary/truncated. */
sha?: string;
}
| { available: false };
/** One entry of `GET /api/lanes/:id/git/tree` — a folded directory or a file. */
export interface LaneTreeEntry {
name: string;
/** Path relative to the repo root. */
path: string;
dir: boolean;
/** A changed file's porcelain code (` M`, `??`, `A `, ` D`…); absent when clean. */
status?: string;
/** A folder with a changed or untracked file anywhere under it. */
changed?: true;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
export interface LaneDiffLine {
type: "ctx" | "add" | "del";
/** Line number on the left (before) side; null on an added line. */
oldLine: number | null;
/** Line number on the right (after) side; null on a removed line. */
newLine: number | null;
text: string;
}
/** One `@@` block of a file's diff. */
export interface LaneDiffHunk {
header: string;
/** The function/section name git puts after the `@@`, when it found one. */
context: string;
oldStart: number;
newStart: number;
lines: LaneDiffLine[];
}
/** One changed file, as `GET /api/lanes/:id/git/changes` reports it. */
export interface LaneChangeFile {
path: string;
/** Two-character porcelain code for the `work` range, `"M "`-style for `branch`. */
status: string;
untracked: boolean;
/** The file has changes in the index (`work` range only). */
staged?: boolean;
/** The file has changes only in the working tree (`work` range only). */
unstaged?: boolean;
/** The file is in a merge conflict (`work` range only). */
conflicted?: boolean;
added: number | null;
deleted: number | null;
binary: boolean;
/** Absent in overview mode (no `full`/`path`), `[]` for a binary file. */
hunks?: LaneDiffHunk[];
/** This file's patch was cut short by a size cap. */
truncated?: boolean;
}
/** `GET /api/lanes/:id/git/changes`. */
export type LaneChanges =
| {
available: true;
range: LaneChangeRange;
/** The lane's base branch name, for `range: "branch"`. */
base: string | null;
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
baseRef: string | null;
/** The commit being shown, for `range: "commit"`; its full sha. */
sha: string | null;
/** Lines of unchanged context each hunk carries. */
context: number;
files: LaneChangeFile[];
/** A cap bit: the file list or some patch was cut short. */
truncated: boolean;
}
| { available: false; range: LaneChangeRange; files: LaneChangeFile[] };
/** Aggregate counts across all lanes. */
export interface LaneCounts {
total: number;
@@ -2692,7 +2812,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",
},
@@ -2700,21 +2820,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",
},
@@ -2900,28 +3020,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",
},
@@ -2931,7 +3051,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>
+68 -49
View File
@@ -132,7 +132,14 @@ interface SystemInfo {
node_version: string;
platform: string;
ws_connections: number;
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
memory: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
heapLimit?: number;
};
cpu_load: number[];
arch: string;
total_mem: number;
@@ -169,7 +176,7 @@ function ChartTooltip({ x, y, children }: { x: number; y: number; children: Reac
const nearRight = x > window.innerWidth - 200;
return (
<div
className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
className="fixed z-50 px-2 py-1.5 text-xs bg-surface-2 border border-border rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
style={{
left: nearRight ? x - 14 : x + 14,
top: y - 10,
@@ -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" />
+24 -15
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 },
@@ -369,7 +372,7 @@ function PricingInfoTooltip() {
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: pos.left, top: pos.top, width: 320 }}
>
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
@@ -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>
+3 -3
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>
@@ -435,10 +435,10 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3.5 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
className="fixed z-50 p-3.5 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-[#2a2a4a]">
<p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-border">
{title}
</p>
+429 -256
View File
@@ -40,10 +40,20 @@
*
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Plus, Square, Columns2, Grid2x2, Pencil, Layers } from "lucide-react";
import {
Plus,
Square,
Columns2,
Grid2x2,
Pencil,
Layers,
PanelLeftClose,
PanelRightClose,
Maximize2,
} from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
@@ -65,10 +75,38 @@ import LaneCard, {
} from "../components/lanes/LaneCard";
import LaneStripCard from "../components/lanes/LaneStripCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
/** localStorage key for the Changes panel's open/closed state. */
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
* 30/70 → 50/50 → 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", "full"] as const;
type ChangesSize = (typeof CHANGES_SIZES)[number];
const CHANGES_WIDTH: Record<ChangesSize, string> = {
compact: "w-[30%]",
half: "w-1/2",
// 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, the full width, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
compact: Columns2,
half: Maximize2,
full: PanelRightClose,
};
// ── Page ──────────────────────────────────────────────────────────────
/** One pane, two side by side, four in a grid — the conventional icons for
@@ -122,7 +160,6 @@ function ConsoleArea({
selectedLaneId,
splitView,
setLayout,
setPaneLaneId,
binaryStatus,
cwdSuggestions,
activeRuns,
@@ -138,7 +175,6 @@ function ConsoleArea({
selectedLaneId: number | null;
splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void;
setPaneLaneId: (index: number, id: number) => void;
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
@@ -150,6 +186,30 @@ function ConsoleArea({
onLaneIdChange: (id: number | null) => void;
showToggle?: boolean;
}) {
// Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave
// that run out of their Active runs, so no terminal is attached twice.
const [paneRunIds, setPaneRunIds] = useState<(string | null)[]>([]);
const onPaneHandle = useCallback((i: number, id: string | null) => {
setPaneRunIds((prev) => {
if (prev[i] === id) return prev;
const next = [...prev];
next[i] = id;
return next;
});
}, []);
const paneHandlers = useMemo(
() => [0, 1, 2, 3].map((i) => (id: string | null) => onPaneHandle(i, id)),
[onPaneHandle]
);
const hiddenFor = useMemo(
() =>
[0, 1, 2, 3].map((i) =>
paneRunIds.filter((id, j): id is string => j !== i && j < splitView.layout && !!id)
),
[paneRunIds, splitView.layout]
);
return (
<>
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
@@ -157,6 +217,7 @@ function ConsoleArea({
<LaneConsolePane
lanes={lanes}
laneId={selectedLaneId}
onHandleChange={paneHandlers[0]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
@@ -174,13 +235,16 @@ function ConsoleArea({
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
}`}
>
{splitView.paneLaneIds.map((id, i) => (
{Array.from({ length: splitView.layout }, (_, i) => (
<LaneConsolePane
key={i}
lanes={lanes}
laneId={id}
showLaneSelector
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
laneId={selectedLaneId}
slot={i}
hiddenRunIds={hiddenFor[i]}
onHandleChange={paneHandlers[i]}
showLaneSelector={false}
onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
@@ -206,6 +270,12 @@ export function Workspace() {
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
// Fields the Add-lane modal opens with when it was opened from a lane's git
// bar ("new lane here"): one provisioning path for every lane, prefilled.
const [addLanePrefill, setAddLanePrefill] = useState<{
sourceRepo?: string;
branch?: string;
} | null>(null);
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]);
const [pipelineTemplates, setPipelineTemplates] = useState<
@@ -239,6 +309,44 @@ export function Workspace() {
// single-pane view and collapsed in split view, but the toggle below can
// override that in either mode — the user decides, not the layout.
const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1);
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
// order (client/src/lib/laneOrderStorage.ts).
const [changesOpen, setChangesOpen] = useState(() => {
try {
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
} catch {
/* private mode / disabled storage: the panel just forgets between visits */
}
}, [changesOpen]);
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
try {
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null;
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact";
} catch {
return "compact";
}
});
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize);
} catch {
/* same as above: a forgotten width, not an error */
}
}, [changesSize]);
const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel =
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const showInfo = infoExpanded;
const setLayout = useCallback((layout: SplitLayout) => {
@@ -250,16 +358,6 @@ export function Workspace() {
});
}, []);
const setPaneLaneId = useCallback((index: number, id: number) => {
setSplitView((prev) => {
const paneLaneIds = [...prev.paneLaneIds];
paneLaneIds[index] = id;
const next = { ...prev, paneLaneIds };
writeSplitViewState(next);
return next;
});
}, []);
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
const refreshLanes = useCallback(async () => {
try {
@@ -485,8 +583,11 @@ export function Workspace() {
// Viewport locked only when a live run is showing (TerminalView needs locked
// viewport for chat scrolling). The config-card screen needs normal page flow
// so the form is fully reachable on short windows.
const viewportLocked = paneHasActiveRun;
// so the form is fully reachable on short windows. A full-width Changes panel
// hides that form anyway, and without a locked height it shrinks to its
// content — the editor inside it collapsed to a few lines (no live run).
const viewportLocked =
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -579,15 +680,19 @@ export function Workspace() {
which reads as a half-rendered row rather than a state. One banner
instead — and a compact one on purpose: the run setup form below
is what the user actually needs the height for. */
<div
<button
type="button"
data-testid="lane-empty"
className="card flex shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4"
onClick={() => setAddLaneOpen(true)}
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
<Layers className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-medium text-fg-primary">{tLanes("empty.title")}</h2>
<span className="block text-sm font-medium text-fg-primary">
{tLanes("empty.title")}
</span>
<p className="mt-0.5 text-xs text-fg-muted">
{tLanes("empty.body")}{" "}
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
@@ -595,16 +700,11 @@ export function Workspace() {
</code>
</p>
</div>
<button
type="button"
data-testid="lane-tile-add"
onClick={() => setAddLaneOpen(true)}
className="btn-primary shrink-0"
>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
<Plus className="h-4 w-4" />
{tLanes("add")}
</button>
</div>
</span>
</button>
) : (
<div
data-testid="lane-strip"
@@ -646,11 +746,19 @@ export function Workspace() {
selection) unmounted the terminal and wiped a half-typed prompt.
Same rule as RunConsole's own collapse — hide with CSS, never by
unmounting. */}
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
>
{/* One header row, not two. The pipeline picker, the stage badges,
{/* Detail and the Changes panel share one row: the panel is a reading
surface for the same lane the console is driving, so it sits beside
it rather than under it. It collapses to a rail instead of
unmounting nothing — there is no live subscription inside it, so
unmounting is safe and keeps a closed panel from polling. */}
<div className="flex min-h-0 flex-1 gap-4">
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}${
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
row below this one; every one of them is a per-lane control that
belongs beside the lane's own name, and stacking them cost a whole
@@ -658,245 +766,306 @@ export function Workspace() {
down. The pipeline's NAME is no longer interpolated into
`laneHeader` either — the picker right next to it already says it.
They stay gated on `showInfo`, same as before the merge. */}
{currentLane && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
{currentLane && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
{currentLane.liveness === "dead"
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
prefix any more: the id is on the strip tile, where the CLI's
`--lane` reader looks for it, and repeating it here only ate
the width the name needed. Submitting an empty name clears
`title`, which puts the cwd back in its place. */}
{renamingLaneId === currentLane.id ? (
<input
data-testid="lane-rename-input"
aria-label={tLanes("renameLabel")}
autoFocus
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
// Blur CANCELS, it does not save — same convention as the
// rename field in CcConfig. Committing here too would fire
// the PATCH twice for one Enter: the commit unmounts the
// focused input, and removing a focused element raises a
// native blur before the refetch has moved `lanes`, so the
// "unchanged title" guard would not catch the second call.
onBlur={() => setRenamingLaneId(null)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(currentLane.id);
if (e.key === "Escape") setRenamingLaneId(null);
}}
maxLength={200}
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
/>
) : (
<button
type="button"
data-testid="lane-rename"
onClick={() => {
setRenameDraft(currentLane.title || "");
setRenamingLaneId(currentLane.id);
}}
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
>
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
{showInfo && (
<>
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
{renamingLaneId === currentLane.id ? (
<input
data-testid="lane-rename-input"
aria-label={tLanes("renameLabel")}
autoFocus
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
// Blur CANCELS, it does not save — same convention as the
// rename field in CcConfig. Committing here too would fire
// the PATCH twice for one Enter: the commit unmounts the
// focused input, and removing a focused element raises a
// native blur before the refetch has moved `lanes`, so the
// "unchanged title" guard would not catch the second call.
onBlur={() => setRenamingLaneId(null)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(currentLane.id);
if (e.key === "Escape") setRenamingLaneId(null);
}}
maxLength={200}
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
/>
) : (
<button
type="button"
data-testid="lane-rename"
onClick={() => {
setRenameDraft(currentLane.title || "");
setRenamingLaneId(currentLane.id);
}}
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
>
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
<LaneRouterRow lane={currentLane} />
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `agenthub stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
>
{tLanes("autoStage", { stage: currentLane.detected_stage })}
</span>
)}
{features.length > 0 && (
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
{showInfo && (
<>
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
<option value="">{tLanes("features.live")}</option>
{features.map((f) => (
<option key={f.slug} value={f.slug}>
{f.slug}
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
{/* Per-lane actions (agents install, mcp sync, sync-base check)
<LaneRouterRow lane={currentLane} />
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
one as its amber badge — so the chips were a second,
smaller copy of the same two facts. */}
{features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
value={viewedFeatureSlug ?? ""}
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
>
<option value="">{tLanes("features.live")}</option>
{features.map((f) => (
<option key={f.slug} value={f.slug}>
{f.slug}
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
</option>
))}
</select>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
{/* Per-lane actions (agents install, mcp sync, sync-base check)
belong beside the lane they act on, not in a page header. */}
<LaneToolsMenu lane={currentLane} />
<button
type="button"
data-testid="lane-detail-toggle"
aria-expanded={infoExpanded}
onClick={() => setInfoExpanded((v) => !v)}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
>
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
</button>
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
{showInfo && (
<LaneToolsMenu lane={currentLane} />
<button
type="button"
data-testid="lane-action-remove"
onClick={() => setRemoveConfirmOpen(true)}
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
data-testid="lane-detail-toggle"
aria-expanded={infoExpanded}
onClick={() => setInfoExpanded((v) => !v)}
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
>
{/* One label for both kinds: removal forgets the lane's row
and never deletes a worktree, a branch or a database. */}
{tLanes("action.forget")}
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
</button>
)}
</div>
</div>
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3">
<LaneCard
lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
{showInfo && (
<button
type="button"
data-testid="lane-action-remove"
onClick={() => setRemoveConfirmOpen(true)}
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
>
{/* One label for both kinds: removal forgets the lane's row
and never deletes a worktree, a branch or a database. */}
{tLanes("action.forget")}
</button>
)}
onSelectLane={setSelectedLaneId}
/>
</div>
</div>
<div className="mb-3">
<PipelineMap
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
>
{tLanes("proof.ticketReport")}
</a>
)}
{currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
{/* 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)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
)}
{Object.entries(proofFeature.groups).map(([group, images]) => (
<div key={group} className="mt-1">
<span className="text-xs text-fg-muted">
{group} · {images.length}
</span>
<div className="flex flex-wrap gap-1">
{images.slice(0, 8).map((img) => (
<img
key={img}
loading="lazy"
className="h-16 w-16 rounded object-cover"
src={api.lanes.proof.imageUrl(
currentLane.id,
proofFeature.slug,
group,
img
)}
alt={img}
/>
))}
{images.length > 8 && (
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
)}
onSelectLane={setSelectedLaneId}
/>
</div>
<div className="mb-3">
<PipelineMap
nodes={
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
}
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
</p>
)}
</div>
{proofFeature &&
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
>
{tLanes("proof.ticketReport")}
</a>
)}
{Object.entries(proofFeature.groups).map(([group, images]) => (
<div key={group} className="mt-1">
<span className="text-xs text-fg-muted">
{group} · {images.length}
</span>
<div className="flex flex-wrap gap-1">
{images.slice(0, 8).map((img) => (
<img
key={img}
loading="lazy"
className="h-16 w-16 rounded object-cover"
src={api.lanes.proof.imageUrl(
currentLane.id,
proofFeature.slug,
group,
img
)}
alt={img}
/>
))}
{images.length > 8 && (
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
<div
className={`flex min-h-0 flex-1 flex-col gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{/* `showToggle`: the lane header carries its own split toggle; with
))}
</div>
)}
</div>
)}
<div
className={`flex min-h-0 flex-1 flex-col gap-2 ${
currentLane ? "border-t border-border pt-3" : ""
}`}
>
{/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead. */}
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
setPaneLaneId={setPaneLaneId}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
/>
</div>
</section>
<ConsoleArea
lanes={lanes}
selectedLaneId={selectedLaneId}
splitView={splitView}
setLayout={setLayout}
binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns}
externalSessions={externalSessions}
wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={setPaneHasActiveRun}
onLaneCreated={(lane) =>
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
}
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
/>
</div>
</section>
{currentLane &&
(changesOpen ? (
<aside
id="lane-changes-panel"
data-testid="lane-changes-card"
className={`card flex shrink-0 flex-col p-3 ${CHANGES_WIDTH[changesSize]}`}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
{tLanes("changes.title")}
</span>
<button
type="button"
data-testid="lane-changes-resize"
onClick={() => {
// Past the widest step the next state is "gone", which
// is what the rail button reopens at `compact`.
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) {
setChangesOpen(false);
setChangesSize("compact");
} else {
setChangesSize(nextChangesSize);
}
}}
// The control names the same region the rail button does,
// so a screen reader reads one panel that is open or
// closed rather than two unrelated buttons.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={changesNextLabel}
title={changesNextLabel}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
{/* Icon-only, and it draws the width the click LANDS on,
not the current one — the label the tooltip carries. */}
{(() => {
const Next = CHANGES_NEXT_ICON[changesSize];
return <Next className="h-4 w-4" />;
})()}
</button>
</div>
<LaneChangesPanel
laneId={currentLane.id}
full={changesSize === "full"}
// 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
type="button"
data-testid="lane-changes-expand"
onClick={() => setChangesOpen(true)}
aria-expanded={false}
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.expand")}
title={tLanes("changes.expand")}
className="card flex w-9 shrink-0 flex-col items-center gap-2 py-3 text-fg-muted transition-colors hover:text-fg-primary"
>
<PanelLeftClose className="h-4 w-4" />
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
{tLanes("changes.title")}
</span>
</button>
))}
</div>
</div>
{currentLane && removeConfirmOpen && (
@@ -915,7 +1084,11 @@ export function Workspace() {
<AddLaneModal
open={addLaneOpen}
cwdSuggestions={cwdSuggestions}
onClose={() => setAddLaneOpen(false)}
prefill={addLanePrefill}
onClose={() => {
setAddLaneOpen(false);
setAddLanePrefill(null);
}}
onAdded={(lane) => {
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
setSelectedLaneId(lane.id);
@@ -0,0 +1,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));
});
});
@@ -0,0 +1,185 @@
/**
* @file Workspace.changesPanel.test.tsx
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
* All of it lives in localStorage and reaches no server — the same rule as the
* lane strip's order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, cleanup, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import i18n from "i18next";
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
// fixtures it reads have to be lifted with it.
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
const laneStub = (id: number, cwd: string) => ({
id,
title: `lane-${id}`,
cwd,
branch: null,
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
});
return {
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
LANE_A: laneStub(1, "/workspace/alpha"),
LANE_B: laneStub(2, "/workspace/beta"),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
run: {
list: r({ items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [HOME] }),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running" }),
},
lanes: {
list: r({
lanes: [LANE_A, LANE_B],
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
gitTree: r({ entries: [] }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Workspace — the Changes panel's width", () => {
beforeEach(() => window.localStorage.clear());
async function openPanel() {
render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
// Open/closed is itself remembered, so the second mount of a test comes up
// already open — clicking the rail then would close it.
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
}
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
await openPanel();
const card = () => screen.getByTestId("lane-changes-card");
expect(card().className).toContain("w-[30%]");
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(card().className).toContain("w-1/2");
// 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 at 30/70, not back
// at full width, which is why closing resets the width.
fireEvent.click(screen.getByTestId("lane-changes-resize"));
await settle();
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
fireEvent.click(screen.getByTestId("lane-changes-expand"));
await settle();
expect(card().className).toContain("w-[30%]");
});
it("remembers the width across a reload, and never asks the server", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
cleanup();
await openPanel();
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
});
it("keeps the rail as the only way back in", async () => {
await openPanel();
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
});
});
+9 -17
View File
@@ -652,46 +652,38 @@ describe("split terminal view", () => {
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
it("switching to 2-pane layout renders two panes bound to the selected lane", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 4-pane layout renders four panes", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
expect(screen.getAllByTestId("console-body")).toHaveLength(4);
});
it("persists the layout and pane selections to localStorage across remounts", async () => {
it("persists the layout to localStorage across remounts", async () => {
const { unmount } = await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
const selects = screen.getAllByTestId("pane-lane-select");
const select = selects[1];
expect(select).toBeDefined();
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
await settle();
unmount();
await renderWorkspace();
const persistedSelects = screen.getAllByTestId("pane-lane-select");
expect(persistedSelects).toHaveLength(2);
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
it("falls back to unselected when a persisted lane id no longer exists", async () => {
it("ignores a persisted per-pane lane id: every pane follows the selected lane", async () => {
localStorage.setItem(
"agenthub.workspace.splitView",
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
);
await renderWorkspace();
// Lane 9999 doesn't exist, so it falls back to null (unselected).
// The second pane is already null. Both render as pane-empty.
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
expect(screen.queryAllByTestId("pane-empty")).toHaveLength(0);
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
});
File diff suppressed because it is too large Load Diff
+54
View File
@@ -8,6 +8,43 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* A status/accent FILL is pre-mixed against one fixed base, never translucent.
*
* `bg-status-success/10` used to emit `rgb(var(--status-success) / 0.1)`, which
* composites with whatever is behind it — so the one "success" chip rendered as
* five different colours over the five surface steps, and washed out badly on
* the light theme's near-white cards. Mixing against `--tint-base` (the card
* surface of the active theme) gives an OPAQUE colour: the same chip reads the
* same on the page, in a card, in a modal and inside an input row.
*
* Applied to `backgroundColor`/`borderColor` only. `text-status-warning/80` and
* `ring-accent/40` stay genuinely translucent — text and rings sit on top of a
* fill rather than replacing it — and the surface family keeps its alpha too,
* or every modal scrim would turn solid.
*/
const tint =
(variable) =>
({ opacityValue }) => {
const alpha = Number(opacityValue);
// No modifier at all: Tailwind passes `var(--tw-bg-opacity, 1)` here.
if (!Number.isFinite(alpha) || alpha >= 1) return `rgb(var(${variable}))`;
return `color-mix(in srgb, rgb(var(${variable})) ${alpha * 100}%, rgb(var(--tint-base)))`;
};
const tinted = {
accent: {
DEFAULT: tint("--accent"),
hover: tint("--accent-hover"),
muted: "color-mix(in srgb, rgb(var(--accent)) 15%, rgb(var(--tint-base)))",
},
status: {
success: tint("--status-success"),
danger: tint("--status-danger"),
warning: tint("--status-warning"),
},
};
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
@@ -37,12 +74,29 @@ export default {
secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
muted: "rgb(var(--fg-muted) / <alpha-value>)",
},
syntax: {
comment: "rgb(var(--syn-comment) / <alpha-value>)",
string: "rgb(var(--syn-string) / <alpha-value>)",
number: "rgb(var(--syn-number) / <alpha-value>)",
keyword: "rgb(var(--syn-keyword) / <alpha-value>)",
builtin: "rgb(var(--syn-builtin) / <alpha-value>)",
function: "rgb(var(--syn-function) / <alpha-value>)",
operator: "rgb(var(--syn-operator) / <alpha-value>)",
punctuation: "rgb(var(--syn-punctuation) / <alpha-value>)",
property: "rgb(var(--syn-property) / <alpha-value>)",
tag: "rgb(var(--syn-tag) / <alpha-value>)",
plain: "rgb(var(--syn-plain) / <alpha-value>)",
add: "rgb(var(--syn-add) / <alpha-value>)",
del: "rgb(var(--syn-del) / <alpha-value>)",
},
status: {
success: "rgb(var(--status-success) / <alpha-value>)",
danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <alpha-value>)",
},
},
backgroundColor: tinted,
borderColor: tinted,
fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
+242 -4
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
@@ -258,6 +261,8 @@ All fields are optional. The tool (CLI invoked) comes from the lane itself, neve
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the lane's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same lane, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
@@ -381,10 +386,17 @@ What the lane's directory looks like to git right now:
"files": [
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
]
],
"hasRemote": true,
"upstream": "origin/feat/rename-metric",
"ahead": 2,
"behind": 1
}
```
- `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
@@ -393,10 +405,233 @@ What the lane's directory looks like to git right now:
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
unstaged work together — and are `null` when git has no delta to report: an
untracked file, a binary file, a rename, or a repo with no commits yet.
- `hasRemote`, `upstream`, `ahead` and `behind` describe the branch's standing
against its remote-tracking ref. A branch that has never been pushed reports
`upstream: null` with `ahead`/`behind` **null** — not `0`, which would claim
"in sync" about a comparison that was never possible.
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
and the lane list is polled and re-broadcast on every hook. See
`docs/LANES.md` for the full treatment.
#### Read a lane's code changes
```http
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=<file>][&full=1][&context=N]
```
One route, three depths — the shapes the Workspace's Changes panel asks for:
| Query | Shape | Used by |
| --- | --- | --- |
| *(none)* | every changed file with its counts, no patch text | overview |
| `full=1` | the same list, each file carrying its `hunks` | review |
| `path=<file>` | that one file, with its `hunks` | single-file view |
| `context=N` | the same, with N unchanged lines around each hunk | the viewer's expand button |
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
untracked files included, `branch` is `<base>...HEAD` for the lane's
`base_branch`, and `commit` shows the single commit named by `sha` (an
unresolvable one is `400 EBADREV`, never an empty diff).
`context` defaults to **3** and is clamped to **5000** — at the top of that
range the diff has effectively become the whole file, which is what "expand
all" means in the panel.
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,
"range": "work",
"base": "development",
"baseRef": "origin/development",
"sha": null,
"context": 3,
"truncated": false,
"files": [
{
"path": "server/lib/git-ops.js",
"status": " M",
"untracked": false,
"staged": false,
"unstaged": true,
"conflicted": false,
"added": 12,
"deleted": 3,
"binary": false,
"hunks": [
{
"header": "@@ -1,2 +1,3 @@",
"context": "runGitAction",
"oldStart": 1,
"newStart": 1,
"lines": [{ "type": "add", "oldLine": null, "newLine": 2, "text": " const x = 1;" }]
}
]
}
]
}
```
- Each line is already resolved to both gutters (`oldLine`/`newLine`, one of
them `null` on an added or removed line), so a client renders rows without
re-parsing `@@` headers.
- `staged`, `unstaged` and `conflicted` come from the porcelain XY columns and
are reported for `range=work` only — they are what the panel's per-file
stage/unstage buttons key off.
- An **untracked** file has no git-side "before", so its patch is synthesized
as one all-added hunk from the file itself. A binary file reports
`binary: true` and `hunks: []`.
- Three caps guard the response: **300** files listed, **200 KB** of patch per
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
the response and on the file it cut.
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
git cannot read; `400 ENOBASE` when `range=branch` on a lane with no base
branch, and `400 ENOFILECHANGE` for a `path` with no changes in that range.
#### Read a lane's commits, a file, or the file tree
```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
PUT /api/lanes/:id/git/file {path, content, baseSha}
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
and file explorer. All four are read-only and unguarded, and all four answer
`{available:false}` with HTTP **200** for a cwd git cannot read.
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`PUT /git/file`** saves the whole-file editor (same-origin guarded;
`server/lib/lane-file-write.js`). Same path guards as the read, plus: the
file must already exist, be text and within the 1 MB cap, and `baseSha`
must equal the `sha` it was opened at — otherwise `409 ESTALE`, never an
overwrite. `403 ESECRET` for `settings.local.json`; `400 EBADPATH`,
`ENOFILE`, `EBADCONTENT`, `ETOOBIG`, `EBINARY`. Answers the file re-read,
fresh `sha` included. CRLF endings and the final newline are preserved.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines, sha?}`
(`sha` only for a whole, textual `work` file: what a save sends back).
`rev=work` (default) is the file on disk — the one branch that reads the
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, status?, changed?}], truncated}`
— one directory level: tracked files plus untracked ones git does not
ignore (`ls-files --cached --others --exclude-standard`), directories folded
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the lane.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) 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
```http
GET /api/lanes/:id/git/branches
```
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
— the local branches of the lane's own repo, for the branch switcher. The repo
comes from the lane row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a lane that does
not exist yet).
#### Act on a lane's working copy
```http
POST /api/lanes/:id/git/:action
```
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
per 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 <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>` (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": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
```
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
running agent. There is deliberately no merge, rebase, reset or stash here —
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
`discard` accept only paths `git status` is reporting as changed right now,
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
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`, `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.
#### Read a lane's runtime
```http
@@ -1628,9 +1863,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
@@ -1666,6 +1901,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.
@@ -1757,6 +1994,7 @@ GET /api/run/binary?tool= { found, path } for the `claude` or `codex
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
GET /api/run/:id Run handle (returns live run state)
+3 -1
View File
@@ -389,7 +389,9 @@ CREATE TABLE notifications (
### model_pricing
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD).
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
```sql
CREATE TABLE model_pricing (
+5 -3
View File
@@ -159,9 +159,11 @@ npm run install-hooks
> **Skip this entirely when the `agenthub` plugin is installed.** The plugin declares
> the same eight hooks itself (inline in `.claude-plugin/plugin.json`, each
> running `${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`). Running both means
> every event is POSTed twice — events carry no id, so ingest cannot deduplicate
> them and every token and cost figure doubles — as does every event in the feed
> and every subagent row (one `Agent` call producing two cards). `scripts/plugin-bootstrap.js`
> every event is POSTed twice. Events carry no id, so ingest can only drop a
> POST whose payload is byte-identical to the session's LATEST event from the last
> 10 seconds (`findRecentDuplicateEvent`, since v1.8.1) — that stops the doubled
> feed rows, token/cost figures and subagent cards, but the second hook process
> still runs on every event, so remove the duplicate registration anyway. `scripts/plugin-bootstrap.js`
> removes checkout-installed entries on session start (backup:
> `~/.claude/settings.json.agenthub-bak`) — and since v1.6.17 a settings file that
> still carries them also blocks the bootstrap's fast path (`hasLegacyHooks`),
+239 -17
View File
@@ -41,9 +41,53 @@ Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-eff
The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`).
Under the *Pipeline template* select, a **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
In Worktree mode the branch question has three answers, because git's own rule
decides which is available (issue #34):
- **New branch** — pick the base to fork from, and optionally a name. Leaving
the name empty is the normal case: the route derives `${LANE_BRANCH_PREFIX}${slug}`
exactly as the CLI does, and the field's placeholder previews that name as you
type the title. The modal no longer demands one.
- **Existing branch** — check a branch that already exists out into the new
worktree, with no base and no new branch. Git allows one worktree per branch,
so any branch another worktree already holds is listed but **disabled**, named
with the directory holding it; picking it would have failed provisioning with
`EBRANCHBUSY` after the lane row already existed.
- **Current branch** — no branch at all: the lane simply *is* the repo, working
on whatever is checked out (`main`). Small and personal projects usually want
this and nothing more, so the choice sits beside the other two rather than
being reachable only from the top-level mode toggle — it switches the modal
to **Repo** mode, which is exactly that behaviour. A worktree lane could not
offer it in any case: git will not check `main` out in a second worktree
while the repo itself holds it.
The modal also accepts a **prefill** (repo + branch). That is what the lane git
bar's **New lane here** opens — a sibling worktree of the same repo on the
branch selected there, created through this same flow rather than a second
provisioning path.
Above the branch fields, once the repo path resolves, the modal lists **that
repo's existing worktrees** (branch + directory, first eight). `GET
/api/lanes/branches?repo=` carries them alongside the branch list — one
subprocess more on a form still being typed into, versus a second round trip
for facts that expire together.
The repo field's autocomplete also offers a **Source repos** group: the
`source_repo` of every lane already provisioned, newest first, above the
session-history recents (`GET /api/run/cwds`, `kind: "repo"`). A worktree lane's
recent cwd is the *worktree*, never the repo it was cut from, so the one path
this field wants had to be retyped every time.
The modal has **no pipeline picker**: every lane it creates is born on
`default` (the server's own default), and the template is changed afterwards
from the lane's own detail header in Workspace, or at creation from the
terminal with `agenthub lanes add --pipeline <id>`. *Model endpoint* is the one
per-lane extra the form still asks for, and it sits in the form itself rather
than behind a disclosure — a disclosure around a single field only hid it.
The **Model endpoint** radio chooses between **Native** (the vendor's own API) and **AI Router** — a profile saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway). Choosing one writes it into the new lane's own config as the lane is created: a claude lane gets the `env` block of its `.claude/settings.local.json`, a codex lane gets a `$CODEX_HOME/<name>.config.toml` layer plus a `-p <name>` on every spawn. With no profile saved the radio stays on Native and says so. Unlike the lane's tool, this one is not immutable: an existing lane's detail header in Workspace carries a **Model** select (beside the pipeline picker) showing its current profile, and picking another calls `POST /api/lanes/:id/router/apply`, rewriting the lane's config and its `router_profile` column together. (The select is hidden when `native` is the only profile — there would be nothing to choose.) A failed apply puts the select back rather than leaving the row claiming a router the lane did not get, and marks itself `⚠` with the error in its tooltip. A session already running in the pane is only HALF switched: Claude Code re-applies an `env` block to a live session when the file changes, but the session's model was resolved at startup, so the previous router's model name keeps being sent to the new endpoint and the next prompt fails with a model-not-found error (switching back to Native included). The route therefore answers `restart_required: true` plus the `run_id` whenever a pane is live, and the select renders a **Restart** button that kills that pane and opens a fresh one — started from the lane id alone, since replaying the old run's `--model` would re-create the same mismatch. Editing or deleting the profile itself in Settings also reaches every lane using it: an edit re-applies it (so a rotated key actually lands), a delete resets those lanes to Native. See `docs/API.md` § AI Router profiles.
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
@@ -370,21 +414,21 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the lane's id as a bare `#23` — not "Lane 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a lane.
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and a primary **Add lane** button. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **No lanes yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a lane is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add lane** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-lane modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the lane tiles — only renders once at least one lane exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a lane fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A lane the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose lane is gone is skipped.
- **Renaming a lane** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the lane's `cwd` back in its place. Renaming is display-only — a managed lane's branch name was slugified from its title once, at creation.
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name (no "Lane 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (agents install / mcp sync / sync-base check / create-update readme, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one lane's `cwd` — in Settings it would need a lane picker just to say which. **Create/update readme file** (Claude lanes only — Codex has no slash commands) starts the lane's terminal run with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-lane modal: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first.
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the lane strip's selection — there is no per-pane lane picker — and each pane owns its own tmux session on that lane: pane 0 is the lane's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane lane id from an older build is ignored.
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the lane strip tile only — the detail card no longer repeats it as a banner.
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
**A console pane follows the lane it shows.** Selecting another lane — from the lane strip in layout 1, or from a pane's own lane picker in layouts 2 and 4 — swaps that pane's cwd, run history and terminal over to the new lane. If the new lane already has a live run in `GET /api/run`, the pane re-attaches to it immediately, so each lane sticks to its own `agenthub-lane-<id>` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
**A console pane follows the lane it shows.** Selecting another lane on the lane strip swaps every pane's cwd, run history and terminal over to the new lane. If the new lane already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (the lane list loads after the page mounts), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
**Picking a session to resume starts it right away.** In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — `POST /api/lanes/:id/start` with that `resumeSessionId` and the session's own `cwd` — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. A resume also **sends no prompt at all** — whatever is left in that box is dropped rather than fired as an extra turn into the session being continued. Type into the tmux terminal once it's attached.
@@ -400,8 +444,12 @@ An external session is deduped against a dashboard run with the same `session_id
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
Live, dashboard-spawned rows (not external — there is no tmux session to kill for those) also carry a **Kill** button, `DELETE /api/run/:id` behind a two-click confirm. This is how you free a lane stuck on a previous live session when you actually want it gone, rather than attached to.
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
**A lane whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The console does not surface that raw error: `LaneConsolePane` catches it, re-reads the lane, and attaches to the run it is already holding instead — with a notice explaining that the previous session is still live, so nothing new started. Killing that session (Active runs list → **Kill**, two clicks, the same confirm convention as discarding a file in the Changes panel) is what actually frees the lane for a fresh Start or a different Resume; the kill goes straight through `DELETE /api/run/:id`, and the lane's own `run_id`/`status` self-heal to idle the next time anything reads the lane (`healRunId` in `routes/lanes.js`), so no separate lane-side call is needed.
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
### Finding or adopting a lane: `POST /api/lanes/ensure`
@@ -424,7 +472,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
```
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
- A relative or missing `cwd` returns `400 EBADCWD`.
@@ -432,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
@@ -457,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
@@ -472,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`.
@@ -481,8 +529,160 @@ 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 panel renders them as
`↑2 ↓1` beside the git actions below.
### Acting on a lane's working copy: the git action row
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 |
| **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 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
click is the wrong place to start an operation whose recovery needs a terminal.
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
inventing a merge commit or a conflict underneath a running agent. **Create**
deliberately does not switch: the usual reason to make a branch from a lane is
to spawn a sibling worktree lane on it, and moving this pane's HEAD under a
working agent is not. **New lane here** goes through the normal Add-lane flow
with the fields prefilled — one provisioning path for every lane, not a second
one hidden in the git bar. 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.
### The Changes panel
To the right of the lane detail sits a collapsible **Changes** card (its state
persists per viewer in `localStorage`). Its header holds **one** control, which
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
height-locked so the panel fills the viewport even with no live run; the whole
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
restores the saved text) → closed (`agenthub.workspace.changesSize`
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
narrow. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
which one is "work" changes several times an hour, so the width is a step, not
a setting — and one button beats a width button wedged beside a collapse button
in the panel's tightest corner. It is icon-only, and the icon it draws is the
width the next click LANDS on (even split → wider → closed), with the wording
left to its tooltip. It
reads the lane's git surface in five modes (**Files** sits first: it is the
only one that answers "show me the code" rather than "show me the change"):
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
(`/git/tree`), which expands folders in place rather than navigating into
them — a folder's children render nested under it, siblings stay visible,
and a toolbar button expands or collapses every folder at once (each
unfetched level lazily calls `/git/tree` the first time it's opened).
Untracked files git does not ignore are listed too, and the tree is
decorated like VS Code's: a modified file is amber with `M`, an untracked
one green with `U` (`A` added, `D` deleted and struck through, `R`
renamed), and a folder holding any of them gets an amber dot. The colours
come with the tree read, so **Refresh** re-reads them.
Opening a file entry shows the whole file (`/git/file`). Its **Edit** button
(any range but a commit) turns that view into a text editor: **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
line endings and final newline. A file the editor cannot take (binary, or
over the 1 MB view cap) says so where the Edit button would be.
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons.
- **Review** — every file's full diff, stacked, for reading a whole change-set.
Clicking a file's header folds it shut; only clicking the **path itself**
opens that file. (A header that navigated on any click made folding a
target-practice exercise against one chevron.) One toolbar button folds or
unfolds all of them. What is remembered is the set of *collapsed* paths, so
a file that appears later is open by default; a lane switch clears it.
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 hunk gets an editor-style **review
bar**, hung just under its last changed line and pinned to the visible
right edge: `^`/`v` to walk the hunks (each brings the next/previous `@@`
line to the top of the ONE scroller, below the sticky file header — a
review diff has no scroll box of its own unless it is large enough to
window, above 500 rows), `n of N`, **Undo**
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`).
The single-file view offers the same bar.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
and landing back at its top is the whole cost of opening one file.
- **History** — the lane's recent commits (`GET /:id/git/log`); opening one
shows that commit's diff (`?range=commit&sha=`).
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
working tree, untracked files included as an all-added patch) and **vs base**
(`<base>...HEAD` — what this lane's branch adds on top of `base_branch`; a lane
with no base says so rather than guessing one). Opening a commit from History
adds a third range, shown as a short-sha chip.
The viewer itself (issue #35) is VS Code-shaped: a **unified / side-by-side**
toggle, syntax highlighting from the tokenizer the conversation view already
ships (`client/src/lib/highlight.ts` — no new dependency), the changed span
inside a modified line picked out by a common prefix/suffix trim, **expand /
collapse** buttons that walk `context` both ways along 3 → 12 → 40 → the whole
file, and a **whole file** switch that reads the file itself instead of its
diff.
A diff scrolls on **both axes inside its own bounded box**, never by growing
the panel: the single-file view takes the panel's remaining height, and each
file in the stacked review list is capped, because a horizontal scrollbar at
the bottom of a 2000-line container is only reachable after scrolling to the
end of the file — the one place nobody needs it.
Side-by-side is two *independently scrolling* columns whose horizontal scroll
is mirrored, not one wide row cut in half: a long line has to be able to run
past its column's edge, and halves inside a single scroller instead painted
over each other with nothing to scroll. The toolbar is two rows for the same
reason — *where you are* (mode, refresh) above, *how you are looking at it*
(range, layout, context, whole file) below — because one row wrapped mid-group
in every panel narrow enough to sit beside a console.
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
one file means nothing unless a commit can take the index as it stands, so the
overview grows a commit box the moment anything is staged — above the list and
pinned while it scrolls, on ONE row with a **Stage all** that stages every listed file
the index lacks in one call and an **Unstage all** that unstages every staged one — it sends
`only: "staged"`, never `add -A`, and it is absent with a clean index because
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. In the `work` range the overview splits like VS
Code's Source Control: **Staged Changes** (what the next commit takes, each row
offering Unstage) above **Changes** (unstaged and untracked, each offering
Stage); a partly staged file appears in both. In the tree, a changed file's
whole row takes its status tint, not only its letter.
Review, single-file, history and browse do **not** poll: re-fetching under the
reader's scroll position is worse than a slightly stale view, and those are the
expensive shapes. Diffs are capped server-side (300 files, 200 KB per file,
1 MB per full read) and say so when a cap bit.
## Viewing lanes
@@ -511,7 +711,7 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal has a *Pipeline template* select listing every template the server reports, so this is chosen at creation; `agenthub lanes add --pipeline <id>` is the same choice from the terminal.
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal does not ask — it always creates on `default`. Pick another with `agenthub lanes add --pipeline <id>` at creation, or change it afterwards from the *Pipeline template* select in the lane's detail header in Workspace.
For a lane that already exists, switch it from the terminal:
@@ -1315,6 +1515,28 @@ Terminate the running session. If the lane has no live run, this is a no-op (no
curl -X POST http://localhost:4820/api/lanes/5/stop
```
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
The git writes the Changes panel and its action row offer. See
"Acting on a lane's working copy" above for what each one refuses and why.
```bash
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "wip: from the dashboard"}'
# Only what the Changes panel staged
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "wip: staged only", "only": "staged"}'
# Irreversible, so `confirm` is required and the path must be one git
# already reports as changed
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
-H "Content-Type: application/json" \
-d '{"paths": ["src/scratch.ts"], "confirm": true}'
```
### clear
Reset the lane's pipeline progress, stages, notes, and inferred stage. The lane itself persists; only its recorded history is cleared.
@@ -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.
+290 -4
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.7.0
version: 1.8.8
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
@@ -10977,9 +10981,291 @@ paths:
description: Absolute path to an existing git repository.
responses:
'200':
description: '{branches: string[], current: string | null}.'
description: '{branches: string[], current: string | null, worktrees: {path, branch?, locked?}[]}. The worktrees ride along so the picker can grey out branches git would refuse to check out twice.'
'400':
description: repo is missing, relative, does not exist, or is not a git repo.
/api/lanes/{id}/git/changes:
get:
tags:
- Lanes
summary: A lane's code changes, at one of three depths
description: Backs the Workspace Changes panel. No extra params = a summary of every changed file; full=1 adds every file's hunks (review mode); path=<file> returns that one file's hunks. range=work (default) is HEAD vs the working tree with untracked files included; range=branch is <base_branch>...HEAD; range=commit needs sha= and shows that one commit. context= widens each hunk (default 3, clamped to 5000) — the viewer's expand button. Lines arrive with both gutters resolved. Capped at 300 files, 200 KB of patch per file and 1 MB per full read; a cap that bit sets `truncated`. Read-only, no same-origin guard.
operationId: getLaneChanges
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: range
in: query
schema:
type: string
enum:
- work
- branch
- commit
default: work
- name: sha
in: query
schema:
type: string
description: 'range=commit only: the commit to show.'
- name: context
in: query
schema:
type: integer
default: 3
maximum: 5000
description: Unchanged lines around each hunk.
- name: path
in: query
schema:
type: string
description: Return only this file, with its hunks.
- name: full
in: query
schema:
type: string
enum:
- '1'
- 'true'
description: Include every file's hunks (review mode).
responses:
'200':
description: '{available, range, base, baseRef, sha, context, files, truncated}, or {available:false} for a cwd git cannot read.'
'400':
description: ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.
'404':
description: Lane not found.
/api/lanes/{id}/git/log:
get:
tags:
- Lanes
summary: The lane's recent commits
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (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
- name: limit
in: query
schema:
type: integer
default: 30
maximum: 200
- name: file
in: query
schema:
type: string
responses:
'200':
description: '{available, commits:[{sha, short, subject, author, date, refs}]}.'
'400':
description: EBADPATH (file= is absolute or has a `..` segment).
'404':
description: Lane not found.
/api/lanes/{id}/git/file:
get:
tags:
- Lanes
summary: One file's whole content at a revision
description: rev=work (the file on disk, untracked included), HEAD, or a commit-ish. The `work` revision is the only one that reads the filesystem and it is realpath-confined to the lane directory. Binary content is reported, never returned. Capped at 1 MB. Read-only, no same-origin guard.
operationId: readLaneFile
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: path
in: query
required: true
schema:
type: string
- name: rev
in: query
schema:
type: string
default: work
responses:
'200':
description: '{available, path, rev, binary, truncated, lines}.'
'400':
description: EBADPATH (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:
get:
tags:
- Lanes
summary: Tracked paths matching a substring (quick-open)
description: Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.
operationId: listLanePaths
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: q
in: query
schema:
type: string
responses:
'200':
description: '{available, paths, truncated, total}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/tree:
get:
tags:
- Lanes
summary: One directory level of tracked files
description: 'The file explorer''s expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.'
operationId: listLaneTree
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: path
in: query
schema:
type: string
default: ''
responses:
'200':
description: '{available, path, entries:[{name, path, dir}], truncated}.'
'400':
description: EBADPATH (the path escapes the lane).
'404':
description: Lane not found.
/api/lanes/{id}/git/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
summary: The lane repo's own local branches
description: Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.
operationId: listLaneOwnBranches
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: '{available, branches, current, worktrees}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/{action}:
post:
tags:
- Lanes
summary: One git write on the lane's working copy
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <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
- name: action
in: path
required: true
schema:
type: string
enum:
- fetch
- pull
- push
- stage
- unstage
- discard
- stage-hunk
- discard-hunk
- commit
- switch
- branch-create
- branch-delete
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
message:
type: string
description: commit only; required, max 4000 chars.
only:
type: string
enum:
- staged
description: commit only; commit the index as it stands, with no add -A.
branch:
type: string
description: switch, branch-create and branch-delete.
base:
type: string
description: branch-create only; defaults to HEAD.
create:
type: boolean
description: switch only; create the branch.
paths:
type: array
items:
type: string
description: stage, unstage, discard; each must be a path git reports changed.
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 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, 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: Unexpected server-side failure.
/api/lanes/{id}/{action}:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.7.0",
"version": "1.8.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.7.0",
"version": "1.8.8",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.7.0",
"version": "1.8.8",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -62,8 +62,8 @@
"import-history": "node scripts/import-history.js",
"reconcile-tokens": "node scripts/import-history.js --reconcile-tokens",
"clear-data": "node scripts/clear-data.js",
"test": "node --test server/__tests__/*.test.js && cd client && npm test",
"test:server": "node --test server/__tests__/*.test.js",
"test": "node scripts/test-server.js && cd client && npm test",
"test:server": "node scripts/test-server.js",
"test:client": "cd client && npm test",
"format": "prettier --write .",
"format:check": "prettier --check .",
+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",
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* @file test-server.js
* @description Runs the server test suite against a PRIVATE tmux server.
* Several suites start real `agenthub-lane-<id>` tmux sessions and then kill
* or reset them, with lane ids from their own throwaway database — on the
* user's default tmux server those names collide with the dashboard's real
* lane panes, and a test run kills whichever live session shares an id
* (including the pane the test was launched from). Dropping `TMUX` and
* pointing `TMUX_TMPDIR` at a fresh directory gives every tmux call — the
* app's own and the tests' direct `execFileSync("tmux", …)` — its own socket.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const root = path.join(__dirname, "..");
const dir = path.join(root, "server", "__tests__");
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".test.js"))
.map((f) => path.join(dir, f));
const args = process.argv.slice(2);
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
const env = { ...process.env, TMUX_TMPDIR: tmuxDir };
delete env.TMUX;
delete env.TMUX_PANE;
const result = spawnSync(process.execPath, ["--test", ...args, ...(args.length ? [] : files)], {
stdio: "inherit",
env,
cwd: root,
});
// Whatever a test leaked dies with the private server, never a real pane.
spawnSync("tmux", ["kill-server"], { env, stdio: "ignore" });
fs.rmSync(tmuxDir, { recursive: true, force: true });
process.exit(result.status ?? 1);
+10
View File
@@ -524,6 +524,15 @@ Request body shape:
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream). `{available:false}` with `200` for a cwd git cannot read. |
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
| `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 + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `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`), `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
@@ -694,6 +703,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+3 -3
View File
@@ -216,13 +216,13 @@ describe("Event-driven alert evaluation", () => {
const ruleId = created.body.rule.id;
const sessionId = `alerts-burst-${Date.now()}`;
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId, seq: 1 });
await postHook("BurstProbe", { session_id: sessionId, seq: 2 });
let feed = await fetch("/api/alerts");
assert.equal(feed.body.alerts.filter((a) => a.rule_id === ruleId).length, 0);
await postHook("BurstProbe", { session_id: sessionId });
await postHook("BurstProbe", { session_id: sessionId, seq: 3 });
feed = await fetch("/api/alerts");
const fired = feed.body.alerts.filter((a) => a.rule_id === ruleId);
assert.equal(fired.length, 1);
+39
View File
@@ -617,6 +617,37 @@ describe("Hook Event Processing", () => {
assert.equal(res.body.error.code, "MISSING_SESSION");
});
it("stores a hook delivered twice only once (double registration)", async () => {
const hook = {
hook_type: "PreToolUse",
data: {
session_id: "hook-dup-1",
tool_name: "Bash",
tool_use_id: "toolu_dup_1",
tool_input: { command: "ls", description: "List files" },
},
};
const first = await post("/api/hooks/event", hook);
const second = await post("/api/hooks/event", hook);
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(second.body.duplicate, true);
const { n } = db
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'hook-dup-1'")
.get();
assert.equal(n, 1);
// A DIFFERENT payload in the same session still lands.
await post("/api/hooks/event", {
...hook,
data: { ...hook.data, tool_use_id: "toolu_dup_2" },
});
const after = db
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'hook-dup-1'")
.get();
assert.equal(after.n, 2);
});
it("should auto-create session and main agent on first PreToolUse", async () => {
const res = await post("/api/hooks/event", {
hook_type: "PreToolUse",
@@ -1865,6 +1896,14 @@ describe("Transcript cache integration", () => {
assert.equal(res.body.server.version, pkg.version);
});
it("reports the V8 heap LIMIT, not just the currently allocated heapTotal", async () => {
const res = await fetch("/api/settings/info");
const { heapUsed, heapTotal, heapLimit } = res.body.server.memory;
assert.ok(Number.isFinite(heapLimit), "heapLimit should be a number");
// heapTotal is what V8 has grown to so far; the limit is the ceiling.
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
});
it("should evict cache entry on SessionEnd", async () => {
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
fs.writeFileSync(
+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,
});
});
});
+662
View File
@@ -0,0 +1,662 @@
/**
* @file Tests for server/lib/git-diff.js and server/lib/git-ops.js against a
* REAL git fixture (a bare origin, a clone, a second clone that pushes behind
* the first one's back). Diff parsing, --ff-only's refusal, the dirty-tree
* guard on `switch` and the one-worktree-per-branch rule are all git's own
* behavior — a mocked git would only test our idea of it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
collectChanges,
listCommits,
listPaths,
listTree,
listTreeAll,
parseUnifiedDiff,
readFileAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
const ORIGIN = path.join(ROOT, "origin.git");
const LANE = path.join(ROOT, "lane");
const OTHER = path.join(ROOT, "other");
const g = (cwd, ...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args);
/** `git status --porcelain` as lines — the XY columns matter, so never trim. */
function porcelain(dir) {
return g(dir, "status", "--porcelain").split("\n").filter(Boolean);
}
function write(dir, file, body) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
function freshFixture() {
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(ROOT, { recursive: true });
g(ROOT, "init", "-q", "--bare", ORIGIN);
const seed = path.join(ROOT, "seed");
g(ROOT, "init", "-q", "-b", "development", seed);
write(seed, "a.txt", "one\ntwo\nthree\n");
write(seed, "keep.txt", "unchanged\n");
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(ROOT, "clone", "-q", ORIGIN, LANE);
g(ROOT, "clone", "-q", ORIGIN, OTHER);
// runGitAction's `commit` uses the repo's own identity (it never injects
// one), so the fixture sets a local one — a CI box may have no global user.
for (const dir of [LANE, OTHER]) {
g(dir, "config", "user.email", "t@h");
g(dir, "config", "user.name", "t");
}
gc(LANE, "checkout", "-qb", "feat/thing");
}
before(freshFixture);
describe("parseUnifiedDiff", () => {
it("resolves both gutters across a hunk", () => {
const hunks = parseUnifiedDiff(
"@@ -10,3 +10,4 @@ function f()\n ctx\n-gone\n+new1\n+new2\n ctx2\n"
);
assert.equal(hunks.length, 1);
assert.equal(hunks[0].context, "function f()");
assert.deepEqual(
hunks[0].lines.map((l) => [l.type, l.oldLine, l.newLine, l.text]),
[
["ctx", 10, 10, "ctx"],
["del", 11, null, "gone"],
["add", null, 11, "new1"],
["add", null, 12, "new2"],
["ctx", 12, 13, "ctx2"],
]
);
});
it("ignores the preamble and the no-newline marker", () => {
const hunks = parseUnifiedDiff(
"diff --git a/x b/x\nindex 1..2 100644\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n"
);
assert.equal(hunks.length, 1);
assert.equal(hunks[0].lines.length, 2);
});
it("returns no hunks for an empty patch", () => {
assert.deepEqual(parseUnifiedDiff(""), []);
});
});
describe("collectChanges — work range", () => {
beforeEach(freshFixture);
it("summarizes tracked edits, deletions and untracked files without hunks", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\nfour\n");
fs.rmSync(path.join(LANE, "keep.txt"));
write(LANE, "new.txt", "fresh\nlines\n");
const out = await collectChanges(LANE, { range: "work" });
assert.equal(out.range, "work");
assert.equal(out.truncated, false);
const byPath = Object.fromEntries(out.files.map((f) => [f.path, f]));
assert.deepEqual(Object.keys(byPath).sort(), ["a.txt", "keep.txt", "new.txt"]);
assert.equal(byPath["a.txt"].added, 2);
assert.equal(byPath["a.txt"].deleted, 1);
assert.equal(byPath["keep.txt"].deleted, 1);
assert.equal(byPath["new.txt"].untracked, true);
assert.equal(byPath["new.txt"].added, 2);
// Overview mode is a summary: no file carries hunks.
assert.ok(out.files.every((f) => f.hunks === undefined));
});
it("returns hunks for every file in full mode", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
const out = await collectChanges(LANE, { range: "work", full: true });
assert.equal(out.files.length, 1);
assert.equal(out.files[0].hunks.length, 1);
const kinds = out.files[0].hunks[0].lines.map((l) => l.type);
assert.ok(kinds.includes("del") && kinds.includes("add"));
});
it("synthesizes an all-added patch for an untracked file", async () => {
write(LANE, "new.txt", "alpha\nbeta\n");
const out = await collectChanges(LANE, { range: "work", file: "new.txt" });
assert.equal(out.files.length, 1);
const lines = out.files[0].hunks[0].lines;
assert.deepEqual(
lines.map((l) => [l.type, l.newLine, l.text]),
[
["add", 1, "alpha"],
["add", 2, "beta"],
]
);
});
it("rejects a path with no changes in the range", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "work", file: "keep.txt" }), {
code: "ENOFILECHANGE",
});
});
it("still returns content when a huge file has no newline to cut on", async () => {
// A minified bundle is ONE line. Cutting the patch at the last newline
// inside the cap would land at index -1 here and hand back an empty
// patch — a file changed by a megabyte rendering as "no textual change".
const { MAX_FILE_BYTES } = require("../lib/git-diff");
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
const entry = out.files[0];
assert.equal(entry.truncated, true);
assert.equal(out.truncated, true);
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
});
it("reports a binary file without trying to diff it", async () => {
fs.writeFileSync(path.join(LANE, "blob.bin"), Buffer.from([0, 1, 2, 0, 3]));
const out = await collectChanges(LANE, { range: "work", full: true });
const blob = out.files.find((f) => f.path === "blob.bin");
assert.equal(blob.binary, true);
assert.deepEqual(blob.hunks, []);
});
});
describe("collectChanges — branch range", () => {
beforeEach(freshFixture);
it("diffs the branch against its base, ignoring uncommitted work", async () => {
write(LANE, "a.txt", "one\ntwo\nthree\nfour\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "add four");
write(LANE, "scratch.txt", "not committed\n");
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
assert.equal(out.baseRef, "origin/development");
assert.deepEqual(
out.files.map((f) => f.path),
["a.txt"]
);
assert.equal(out.files[0].added, 1);
});
it("refuses when the lane has no base to measure against", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: null }), {
code: "ENOBASE",
});
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
code: "ENOBASE",
});
});
});
describe("gitFacts tracking counts", () => {
beforeEach(freshFixture);
it("reports null ahead/behind for a branch with no upstream", async () => {
const facts = await gitFacts(LANE);
assert.equal(facts.branch, "feat/thing");
assert.equal(facts.hasRemote, true);
assert.equal(facts.upstream, null);
assert.equal(facts.ahead, null);
});
it("counts both sides once an upstream exists", async () => {
gc(LANE, "checkout", "-q", "development");
write(LANE, "a.txt", "one\ntwo\nthree\nlocal\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "local");
write(OTHER, "b.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote");
gc(OTHER, "push", "-q", "origin", "development");
g(LANE, "fetch", "-q", "origin");
const facts = await gitFacts(LANE);
assert.equal(facts.upstream, "origin/development");
assert.equal(facts.ahead, 1);
assert.equal(facts.behind, 1);
});
});
describe("runGitAction", () => {
beforeEach(freshFixture);
it("rejects an unknown action", async () => {
await assert.rejects(() => runGitAction(LANE, "rebase"), { code: "EBADACTION" });
});
it("commits everything with the given message", async () => {
write(LANE, "a.txt", "changed\n");
write(LANE, "added.txt", "new\n");
const result = await runGitAction(LANE, "commit", { message: "feat: both files" });
assert.equal(result.ok, true);
assert.equal(g(LANE, "log", "-1", "--format=%s").trim(), "feat: both files");
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
});
it("refuses a commit with no message and one with nothing to commit", async () => {
await assert.rejects(() => runGitAction(LANE, "commit", { message: " " }), {
code: "EBADMESSAGE",
});
await assert.rejects(() => runGitAction(LANE, "commit", { message: "empty" }), {
code: "ENOCHANGES",
});
});
it("pulls fast-forward only and refuses a diverged branch", async () => {
gc(LANE, "checkout", "-q", "development");
write(OTHER, "b.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote");
gc(OTHER, "push", "-q", "origin", "development");
const ff = await runGitAction(LANE, "pull");
assert.equal(ff.ok, true);
assert.ok(fs.existsSync(path.join(LANE, "b.txt")));
// Diverge: a local commit here, a different one pushed there.
write(LANE, "c.txt", "local\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "local");
write(OTHER, "d.txt", "remote2\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote2");
gc(OTHER, "push", "-q", "origin", "development");
await assert.rejects(() => runGitAction(LANE, "pull"));
// The refusal left no merge in progress.
assert.ok(!fs.existsSync(path.join(LANE, ".git", "MERGE_HEAD")));
});
it("pushes the current branch and sets its upstream", async () => {
write(LANE, "a.txt", "changed\n");
await runGitAction(LANE, "commit", { message: "wip" });
const result = await runGitAction(LANE, "push");
assert.equal(result.ok, true);
assert.equal(
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
"origin/feat/thing"
);
});
it("switches branches only with a clean tree", async () => {
write(LANE, "a.txt", "dirty\n");
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
code: "EDIRTY",
});
gc(LANE, "checkout", "-q", "--", "a.txt");
const result = await runGitAction(LANE, "switch", { branch: "development" });
assert.equal(result.branch, "development");
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "development");
});
it("creates a branch only when it does not already exist", async () => {
await runGitAction(LANE, "switch", { branch: "feat/second", create: true });
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/second");
await assert.rejects(
() => runGitAction(LANE, "switch", { branch: "feat/thing", create: true }),
{
code: "EBRANCHEXISTS",
}
);
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "nope/missing" }), {
code: "ENOBRANCH",
});
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "..bad" }), {
code: "EBADBRANCH",
});
});
it("refuses a branch another worktree holds", async () => {
const wt = path.join(ROOT, "sibling");
g(LANE, "worktree", "add", "-q", wt, "development");
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
code: "EBRANCHBUSY",
});
});
});
describe("collectChanges — commit range", () => {
beforeEach(freshFixture);
it("reads one commit's files and hunks", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
gc(LANE, "commit", "-qam", "tweak a");
const sha = g(LANE, "rev-parse", "HEAD").trim();
const out = await collectChanges(LANE, { range: "commit", sha, full: true });
assert.equal(out.sha, sha);
assert.deepEqual(
out.files.map((f) => f.path),
["a.txt"]
);
assert.ok(out.files[0].hunks.some((h) => h.lines.some((l) => l.type === "add")));
});
it("rejects a sha that is not a commit here", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "nope" }), {
code: "EBADREV",
});
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "a b;rm" }), {
code: "EBADREV",
});
});
it("widens a hunk when context is raised", async () => {
write(LANE, "long.txt", Array.from({ length: 40 }, (_, i) => `line${i}`).join("\n") + "\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "long");
write(
LANE,
"long.txt",
Array.from({ length: 40 }, (_, i) => (i === 20 ? "CHANGED" : `line${i}`)).join("\n") + "\n"
);
const narrow = await collectChanges(LANE, { range: "work", file: "long.txt" });
const wide = await collectChanges(LANE, { range: "work", file: "long.txt", context: 20 });
const count = (o) => o.files[0].hunks.reduce((n, h) => n + h.lines.length, 0);
assert.equal(narrow.context, 3);
assert.equal(wide.context, 20);
assert.ok(count(wide) > count(narrow));
});
});
describe("listCommits / readFileAt / listPaths / listTree", () => {
beforeEach(freshFixture);
it("lists commits, newest first, and narrows to one file", async () => {
write(LANE, "a.txt", "two\n");
gc(LANE, "commit", "-qam", "touch a");
write(LANE, "keep.txt", "edited\n");
gc(LANE, "commit", "-qam", "touch keep");
const all = await listCommits(LANE, { limit: 10 });
assert.deepEqual(
all.map((c) => c.subject),
["touch keep", "touch a", "init"]
);
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
const forA = await listCommits(LANE, { file: "a.txt" });
assert.deepEqual(
forA.map((c) => c.subject),
["touch a", "init"]
);
});
it("reads a file at work and at a revision, and refuses to escape the lane", async () => {
write(LANE, "a.txt", "one\nEDITED\n");
const work = await readFileAt(LANE, "a.txt", "work");
assert.deepEqual(work.lines, ["one", "EDITED"]);
const head = await readFileAt(LANE, "a.txt", "HEAD");
assert.deepEqual(head.lines, ["one", "two", "three"]);
await assert.rejects(() => readFileAt(LANE, "../../etc/passwd", "work"), { code: "EBADPATH" });
await assert.rejects(() => readFileAt(LANE, "/etc/passwd", "work"), { code: "EBADPATH" });
await assert.rejects(() => readFileAt(LANE, "a.txt", "no such rev"), { code: "EBADREV" });
await assert.rejects(() => readFileAt(LANE, "ghost.txt", "work"), { code: "ENOFILE" });
});
it("quick-opens by substring, basename hits first, ignoring untracked files", async () => {
write(LANE, "src/api.ts", "x\n");
write(LANE, "src/api/helper.ts", "x\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "untracked-api.ts", "x\n");
const out = await listPaths(LANE, { q: "api" });
assert.equal(out.paths[0], "src/api.ts");
assert.ok(out.paths.includes("src/api/helper.ts"));
assert.ok(!out.paths.includes("untracked-api.ts"));
});
it("folds the tree one directory level at a time", async () => {
write(LANE, "src/api/helper.ts", "x\n");
write(LANE, "src/main.ts", "x\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
const root = await listTree(LANE, {});
assert.deepEqual(
root.entries.map((e) => [e.name, e.dir]),
[
["src", true],
["a.txt", false],
["keep.txt", false],
]
);
const src = await listTree(LANE, { subdir: "src" });
assert.deepEqual(
src.entries.map((e) => e.path),
["src/api", "src/main.ts"]
);
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
});
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
write(LANE, "src/main.ts", "m\n");
write(LANE, ".gitignore", "*.log\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "a.txt", "modified\n");
write(LANE, "new.txt", "fresh\n");
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
write(LANE, "noise.log", "ignored\n");
const root = await listTree(LANE, {});
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
assert.equal(by["a.txt"].status, " M");
assert.equal(by["new.txt"].status, "??");
assert.equal(by["keep.txt"].status, undefined);
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
assert.equal(by.gen.changed, true);
assert.equal(by.src.changed, undefined);
// Inside a folder git reports only as "gen/": still untracked, still marked.
const deep = await listTree(LANE, { subdir: "gen/deep" });
assert.equal(deep.entries[0].status, "??");
const all = await listTreeAll(LANE);
assert.equal(all.levels["gen"][0].changed, true);
assert.deepEqual(all.levels[""], root.entries);
});
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", () => {
beforeEach(freshFixture);
it("stages and unstages one file, leaving the other alone", async () => {
write(LANE, "a.txt", "edited\n");
write(LANE, "keep.txt", "also edited\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
assert.deepEqual(porcelain(LANE), ["M a.txt", " M keep.txt"]);
await runGitAction(LANE, "unstage", { path: "a.txt" });
assert.deepEqual(porcelain(LANE), [" M a.txt", " M keep.txt"]);
});
it("refuses a path git is not reporting as changed", async () => {
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["keep.txt"] }), {
code: "EBADPATH",
});
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["../../etc/passwd"] }), {
code: "EBADPATH",
});
await assert.rejects(() => runGitAction(LANE, "stage", {}), { code: "EBADPATH" });
});
it("commits only the index when asked", async () => {
write(LANE, "a.txt", "staged\n");
write(LANE, "keep.txt", "left behind\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
const out = await runGitAction(LANE, "commit", { message: "only a", only: "staged" });
assert.equal(out.ok, true);
assert.deepEqual(porcelain(LANE), [" M keep.txt"]);
});
it("refuses a staged-only commit with an empty index", async () => {
write(LANE, "a.txt", "unstaged\n");
await assert.rejects(() => runGitAction(LANE, "commit", { message: "nope", only: "staged" }), {
code: "ENOCHANGES",
});
});
it("discards a tracked file and deletes an untracked one, but only on confirm", async () => {
write(LANE, "a.txt", "edited\n");
write(LANE, "junk.txt", "throwaway\n");
await assert.rejects(() => runGitAction(LANE, "discard", { paths: ["a.txt"] }), {
code: "ECONFIRM",
});
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "edited\n");
await runGitAction(LANE, "discard", { paths: ["a.txt", "junk.txt"], confirm: true });
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "one\ntwo\nthree\n");
assert.ok(!fs.existsSync(path.join(LANE, "junk.txt")));
});
it("discards a staged edit in both gutters", async () => {
write(LANE, "a.txt", "edited\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
await runGitAction(LANE, "discard", { paths: ["a.txt"], confirm: true });
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
});
it("creates a branch without moving HEAD, and deletes it again", async () => {
const before = g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim();
await runGitAction(LANE, "branch-create", { branch: "feat/sibling" });
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), before);
assert.ok(g(LANE, "branch", "--list", "feat/sibling").trim());
await assert.rejects(() => runGitAction(LANE, "branch-create", { branch: "feat/sibling" }), {
code: "EBRANCHEXISTS",
});
await runGitAction(LANE, "branch-delete", { branch: "feat/sibling" });
assert.equal(g(LANE, "branch", "--list", "feat/sibling").trim(), "");
});
it("refuses to delete a protected, current, missing or busy branch", async () => {
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "development" }), {
code: "EPROTECTED",
});
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/thing" }), {
code: "EBRANCHBUSY",
});
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "nope" }), {
code: "ENOBRANCH",
});
await runGitAction(LANE, "branch-create", { branch: "feat/held" });
g(LANE, "worktree", "add", "-q", path.join(ROOT, "held"), "feat/held");
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/held" }), {
code: "EBRANCHBUSY",
});
});
});
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" }
);
});
});

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