diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d374f9e..cc080732 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -470,7 +470,7 @@ graph TD ### Splash & loading UX - **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies. -- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per theme, consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of. +- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of. - **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`. - **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts). - **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover. @@ -606,7 +606,7 @@ graph LR | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) | | `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | -| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | +| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title | | `/*` | NotFound | None (static 404 page) | ### Activity Feed Interaction Model @@ -2397,7 +2397,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown) | **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages | | **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA | | **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly | -| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light toggle re-themes the app with one class flip rather than per-component `dark:` variants | +| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables | | **React Router 6** | Standard routing for React SPAs. Layout routes with `` give clean shell composition | | **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) | | **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues | diff --git a/CLAUDE.md b/CLAUDE.md index 8c8ec6f6..0d95eb96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.8.3 (2026-09-23) + `GET /api/settings/info` reporting `server.memory.heapLimit` (V8's `heap_size_limit`), which the Dashboard's V8 Heap bar and Health Score now divide by instead of `heapTotal` + every `claude plugin install|update -y` (`cc-plugin-cli.js`, `skill-plugins.js`, both detached update helpers, `/agenthub-update`) retrying WITHOUT `-y` when an older Claude Code answers `unknown option '-y'` + the light theme going WARM (`client/src/index.css`: the whole light ramp is Claude's ivory/terracotta family — page `#e8e5da`, `.card` `#f5f3ec`, warm borders and a warm `--shadow-card`, `--accent` `#a34a28` which is the brand `#cc785c` two steps down because the brand value is 3.1:1 on ivory and 3.3:1 under white text — dark mode is untouched and stays Radix slate + blue) + status/accent FILLS being pre-mixed instead of translucent (`tint()` in `client/tailwind.config.js` + `--tint-base` in both themes of `client/src/index.css`: `bg-status-success/10` and `border-status-danger/40` now compile to `color-mix(…, rgb(var(--tint-base)))`, because `rgb(… / 0.1)` composites with whatever sits behind it — one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. Scoped to `backgroundColor`/`borderColor` ONLY: `text-status-warning/80` and `ring-accent/40` sit on top of a fill and must stay translucent, and a translucent `surface` is what every scrim is made of. Light `--status-*` also dropped two steps below Radix 11 so a label clears 4.5:1 on its own densest chip, not just on the card) + the LIGHT theme being rebuilt around the rule that the surface ramp rises in lightness with its index in BOTH themes (`client/src/index.css`: page `surface-0` #eef0f3 under a brighter `.card` `surface-3` #f8f9fb, so hovers lift; light `--accent` #0068d6 because dark's #0090ff is 3.3:1 on white; `--fg-muted` held at ≥4.5:1; new `--syn-*` syntax tokens + `syntax.*` in `tailwind.config.js` consumed by `tokenClass` in `client/src/lib/highlight.ts`, dark keeping its pastels and light taking the same hues four steps darker) — a hardcoded near-black panel, a `hover:text-white` or a global `filter: invert(1)` is a light-mode BUG, token it or scope it under `.dark` + the lane console attaching to a lane's still-live run instead of failing on `409 ERUNLIVE` (`LaneConsolePane.tsx`, checked by `.code`), with a two-click **Kill** in Active runs (`RunHistory.tsx`) + the prompt box sending on Enter (Shift+Enter newline, IME-safe, `RunSetup.tsx`) + hook ingest dropping a byte-identical redelivery of the session's latest event (`findRecentDuplicateEvent` in `server/db.js`, guard in `POST /api/hooks/event`) + the Add-lane modal having NO pipeline picker and no Advanced disclosure (`AddLaneModal.tsx`: every lane is created on the server's own `default`, changed afterwards from the lane's detail header or at creation via `agenthub lanes add --pipeline `; the router radio is the one per-lane extra left and it sits in the form) + the lane-detail header dropping its stage and `auto: ` chips (the PipelineMap directly below already draws both) + the Changes panel's ONE resize control being icon-only, drawing the width the next click lands on + `client/src/components/Tooltip.tsx` — `GlobalTooltip`, mounted once in `App.tsx`, upgrades EVERY native `title` in the app into an anchored, themed bubble (parks the attribute in `data-tip` while the bubble is up and restores it after, so the accessible name and the no-JS fallback survive; `data-tip-off` opts out, `Tip.tsx` shares its `TOOLTIP_SURFACE`) — do NOT add a per-call-site tooltip wrapper, keep writing `title=` + the lane git surface of issues #34 and #35 — `server/lib/git-diff.js` (a lane's changes as parsed hunks, `range=work|branch|commit`, a caller-chosen `context`, three caps, plus `listCommits`/`readFileAt`/`listPaths`/`listTree`) behind `GET /api/lanes/:id/git/changes|log|file|ls|tree`, `server/lib/git-ops.js` (fetch/pull/push/stage/unstage/discard/commit/switch/branch-create/branch-delete — a per-file path must be one `git status` reports as changed, and `discard` needs `confirm: true`) behind `POST /api/lanes/:id/git/:action`, the Changes panel's five modes (**Files** first) with a staged-only commit box in its overview and per-file collapse in its review list (the header folds, only the PATH opens the file; plus collapse/expand-all) and a **back** that returns to the originating mode AND scroll offset, ONE header control that steps narrow/50-50/70-30/closed (`agenthub.workspace.changesSize` beside `changesOpen`), and its VS Code-shaped viewer (`LaneDiffView.tsx`: a diff bounded to its own box and scrolling on BOTH axes — a horizontal bar at the foot of a 2000-line container is only reachable at the end of the file — unified/side-by-side as two independently scrolling columns with MIRRORED `scrollLeft` — halves inside one scroller painted over each other with nothing to scroll — highlighting reused from `client/src/lib/highlight.ts`, intra-line emphasis, expand AND collapse context, whole-file), the Add-lane modal's third branch answer **Current branch** (it switches the modal to Repo mode — a lane that simply IS the repo on `main`, which a worktree could not do anyway because git holds one worktree per branch) and its `prefill` prop, driven by the git bar's **New lane here**, `gitFacts` gaining `hasRemote`/`upstream`/`ahead`/`behind`, `GET /api/lanes/branches` gaining `worktrees`, `GET /api/run/cwds` gaining a `repo` kind (the `source_repo` of existing lanes), the Add-lane modal's new/existing branch toggle + worktree list + Advanced disclosure (`AddLaneModal.tsx`), and the two new client components `LaneGitBar.tsx` (in `LaneCard`) and `LaneChangesPanel.tsx` (the collapsible Changes card beside `lane-detail` in `Workspace.tsx`, `agenthub.workspace.changesOpen`) + v1.6.17 (2026-09-18) + the run's permission mode defaulting to `auto` and the model picker sending ALIASES (`ALLOWED_PERMISSION_MODES` in `server/lib/pty-run.js` + `server/routes/run.js` gained `auto`, which is also the fallback `buildClaudeArgv` passes to `--permission-mode`, the `RunSetup` select's first option and `LaneConsolePane`'s initial state; and `RUN_MODEL_CHOICES` in `client/src/lib/api.ts` now holds `fable`/`opus`/`sonnet`/`haiku` instead of pinned ids — `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here, and pricing is unaffected because it keys off the resolved id the transcript reports) + `convertEol` being GONE from the xterm terminal (`TerminalView.tsx`) — it rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below: the torn left edge of issue #33, which the attach-size fix alone did NOT cure + the lane pane inheriting the graphical session's `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (`server/lib/desktop-env.js`, merged into `spawnRun`'s tmux `-e` env) — a systemd user service has none of them, so `claude` in a dashboard-started pane could not read the clipboard and answered "no image found in clipboard" on every image paste + the PTY attach carrying the client's REAL size (`sizeFromQuery` in `server/websocket.js`, `?cols=&rows=` on the `/ws-pty/:runId` URL built in `TerminalView.tsx`) — `attach()` was spawning `tmux attach-session` at a hardcoded 80×24 on EVERY attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed and that width stayed in the scrollback as the torn left-margin fragments of issue #33 + the Workspace's no-lane state being a real banner (`lane-empty` in `client/src/pages/Workspace.tsx`, `empty.title`/`empty.body` in `lanes.json` replacing `emptyState`) — the lane strip, add tile included, now renders only when a lane exists + the **Create/update readme file** action living in the lane's **Tools** menu (`LaneToolsMenu` in `client/src/components/lanes/LaneCard.tsx`, `actions.writeDocs*` in `lanes.json`) instead of being a checkbox in the Add-lane modal — same `api.run.start({initialPrompt: "/sg-all:sg-md-writing"})`, claude lanes only, but re-runnable long after creation + the lane terminal taking focus on mount and reporting its own death (`TerminalView.tsx`: `term.focus()` after the first fit, so typing reaches tmux without clicking the pane, and a new `onExit` prop — read through a ref so an inline parent callback never re-attaches the socket — fired on the server's `exit` frame; `LaneConsolePane` runs `newRun()` there, so a double Ctrl+C out of `claude` lands back on the run setup form instead of a dead pane behind a **New run** button) + the run console pre-filling NOTHING into the prompt box and a resume sending NO prompt (`LaneConsolePane.tsx`'s `promptPrefill` is gone — picking a pipeline used to write its driving slash command into the box on a claude lane and a stage-declaration checklist on a codex one, and whatever sits there is sent as a real prompt the moment the run starts; `handleStart` in `RunSetup.tsx` now drops `initialPrompt` entirely when a session is being resumed, since a resume carries its own transcript and anything typed would fire an extra turn into it) + browser notifications falling back when the push landed NOWHERE (`notify()` in `client/src/hooks/useNotifications.ts`): `POST /api/push/send` answers 200 with `{native:false, pushed:0}` whenever this browser has no push subscription — no service worker, a non-secure origin like `http://:4820`, an unreachable push service — and the client counted that as delivered, so nothing ever appeared; delivery is now `native || pushed > 0` and anything else shows the notification locally (skipped when the push DID land, or every one would be doubled). The `Notification` API still needs a secure context, so a bare LAN IP can show nothing at all + auth-profile switching reading and writing the macOS **login Keychain** (`readCredentials`/`writeCredentials` in `server/lib/cc-profiles.js`): on darwin the `claude` CLI keeps `claudeAiOauth` in the generic-password item `Claude Code-credentials` (account `$USER`), never in `/.credentials.json` — so a switch rewrote a file nothing reads and the CLI kept running as the previous account. The Keychain wins when it holds an item, the file stays the fallback and is kept in step only when it already exists, and `AGENTHUB_CC_KEYCHAIN=0` pins the file (the test suite sets it so a Mac dev run never touches the real Keychain) + turn bookkeeping being idempotent (`stmts.insertEventAt` in `server/db.js`, used for `TurnDuration` with the TRANSCRIPT's timestamp — its dedup guard matches on `created_at`, so a NOW stamp made every hook re-insert the session's whole turn history: 587k rows, 75% of the events table — and `meta.thinking_blocks`/`turn_count`/`total_turn_duration_ms` now ASSIGNED from the full parse instead of `+=`, since `extract()` returns whole-transcript totals every time) + `hasLegacyHooks` blocking `fastPathOk` (`scripts/plugin-bootstrap.js`): leftover checkout hook entries double EVERY event and subagent row, `stripLegacyHooks` only ever ran inside `bootstrap()`, and the fast path skipped it on exactly the healthy installs where it mattered + the one-time repair marker moving to `user_version` 3 (`server/index.js`) — it now also deletes the duplicated `TurnDuration` rows, and 2 could not be trusted because an old build still ingesting hooks re-inflated whatever that pass corrected + a router switch on a LIVE pane reporting itself (`POST /api/lanes/:id/router/apply` now answers `restart_required`/`run_id` when `runs.getRun(runIdForLane(id))` is live, and `LaneRouterRow` renders a **Restart** button that kills that pane and restarts it from the lane id alone — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails) + the sidebar brand being the SmartGift wordmark with the `AgentHub` caption under it and the vendored `client/public/favicon.ico` (`Sidebar.tsx`, `client/index.html`, `client/public/manifest.json`) + the two "Claude is waiting for YOU" notification types (`client/src/lib/notifPrefs.ts` — the ONE definition of the prefs shape/defaults/storage, now imported by both `Settings.tsx` and `useNotifications.ts`; `onAwaitingInput` and `onTurnComplete` default ON, issue #29) + per-message token counting (`lastUsageMsgId` in `server/lib/transcript-cache.js` and both parsers in `scripts/import-history.js`: Claude Code writes ONE JSONL line per content block of an assistant message and each repeats the same message-level `usage`, so summing lines over-counted tokens and cost ~1.5–2× on tool-heavy sessions) + `stmts.setTokenUsage` (`server/db.js`) — the EXACT counterpart of `replaceTokenUsage`, used only by `reconcileTokens`, because the high-water `baseline_*` columns make a downward CORRECTION impossible; the server runs that reconcile once in the background at the first start after upgrading (`user_version` 2, `server/index.js`) + v1.6.12 (2026-09-18) + the same three rules in `skills/ship-feature-lane/SKILL.md` (three scheduled touchpoints now: Stage 0 Q&A, Stage 0b plan approval, Stage 7 acceptance before the PR is published — the publish rule and the turn-liveness rule name them) + the `default` 8-node pipeline asking the user twice and looping on a NO-GO (`skills/ship-feature/SKILL.md` + `server/data/pipelines/default.json`: `intake` now DECLARES `/superpowers:brainstorming` and detects it, the `plan` phase blocks on an explicit approval, `ship-feature` gained the four sections that make a long run followable and affordable (**Right-size the run** — trivial work does not enter the pipeline and a small change skips the plan-debate/independent-test/gate agents, **Dispatching a judgement** — the fresh-context/foreground/never-fork/never-dispatch-what-a-command-answers contract written ONCE instead of re-argued per phase, **Resuming after a compaction**, and a per-phase **Leave when:**), `review` DECLARES the bare `/code-review` (Claude Code's own, no `plugin:` prefix — `requiredSkillPlugins` skips it, so it adds no install requirement) and runs it always and a domain reviewer only for the layers `git diff --name-only` shows, and `gate` requires the user's acceptance — a NO-GO re-declares `implementing` or `plan` instead of proceeding) + `KEEP_RETIRED_ROOTS` dropping to 1 (`scripts/plugin-bootstrap.js`) + lane removal being record-only (the `remove` branch in `server/routes/lanes.js` no longer calls `removeWorktree`/`removeLaneData`, so a worktree, its branch and its database survive forgetting a lane — see the rule under **Lanes**) + v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.8.3 (2026-09-23) + `GET /api/settings/info` reporting `server.memory.heapLimit` (V8's `heap_size_limit`), which the Dashboard's V8 Heap bar and Health Score now divide by instead of `heapTotal` + every `claude plugin install|update -y` (`cc-plugin-cli.js`, `skill-plugins.js`, both detached update helpers, `/agenthub-update`) retrying WITHOUT `-y` when an older Claude Code answers `unknown option '-y'` + the Add-lane modal asking `POST /api/lanes/ensure` with `exact: true` (#42) — without it a directory NESTED inside an existing lane resolves to that parent lane with `created: false`, so the modal closed on the parent and no new card ever appeared; `exact` counts only a lane on that very directory, and a `created: false` answer now names the lane that owns the folder instead of closing. The CLI, the run console and Sagi keep the parent lookup + Settings → **Appearance** (`client/src/components/settings/AppearanceSettings.tsx`: mode Dark/Light/**System** — `prefers-color-scheme`, followed live — plus one NAMED theme remembered per mode: dark Default/Darcula/Apple Dark, light Default/Apple Light/IntelliJ Light/Canvas). A theme is a CSS-variable override block in `client/src/index.css` keyed by `data-theme` on `` (`.dark[data-theme=…]` / `[data-mode="light"][data-theme=…]`, `data-mode` is set on `` too), and `--tint-base`/light `--syn-plain` are now `var(--surface-3)`/`var(--fg-primary)` so no theme restates them; `hooks/useTheme.ts` is a `useSyncExternalStore` reader over `localStorage` (`theme` = `dark|light|system`, `agenthub.theme.dark|light`) that caches NOTHING in the module, so the Sidebar and Settings can never disagree — do not reintroduce per-instance `useState` there; an inline ` diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index fce97ecf..141d9c71 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -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). @@ -545,13 +546,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {

{t("nav:theme")}

-
- {(["dark", "light"] as const).map((option) => { - const active = option === theme; +
+ {(["dark", "light", "system"] as const).map((option) => { + const active = option === mode; return ( ); diff --git a/client/src/components/__tests__/Sidebar.theme.test.tsx b/client/src/components/__tests__/Sidebar.theme.test.tsx new file mode 100644 index 00000000..7171d006 --- /dev/null +++ b/client/src/components/__tests__/Sidebar.theme.test.tsx @@ -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ĩ + */ + +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( + + {}} /> + + ); +} + +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 ", 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(); + }); +}); diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index 20516731..07b955a0 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -246,6 +246,8 @@ export function AddLaneModal({ cwd: repo, title: name || undefined, tool, + // THIS directory, not a lane on a parent of it (#42). + exact: true, }); // Report the lane the INSTANT it exists server-side. Everything after // this is post-creation setup that can fail on its own, and letting one @@ -253,6 +255,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. diff --git a/client/src/components/lanes/LaneDiffView.tsx b/client/src/components/lanes/LaneDiffView.tsx index 20c982ba..7ba61e42 100644 --- a/client/src/components/lanes/LaneDiffView.tsx +++ b/client/src/components/lanes/LaneDiffView.tsx @@ -45,7 +45,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number return ( <> - + @@ -120,12 +120,21 @@ function Row({ tint, children }: { tint: string; children: React.ReactNode }) { return
{children}
; } +/** 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/10"; - if (type === "del") return "bg-status-danger/10"; + if (type === "add") + return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]"; + if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]"; return ""; } +function markerClass(type: LaneDiffLine["type"]) { + if (type === "add") return "font-bold text-status-success"; + if (type === "del") return "font-bold text-status-danger"; + return "text-fg-muted"; +} + function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) { const at = hunk.header.split("@@")[1]; return ( @@ -153,7 +162,7 @@ function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) { - + {line.type === "add" ? "+" : line.type === "del" ? "-" : " "} diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index a891aab2..0b97e374 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -503,6 +503,7 @@ describe("AddLaneModal — repo mode (adopt)", () => { cwd: "/Users/tester/projects/repo", title: "main repo", tool: "claude", + exact: true, }) ); expect(api.lanes.worktree).not.toHaveBeenCalled(); @@ -511,6 +512,28 @@ describe("AddLaneModal — repo mode (adopt)", () => { // close right away like the old adopt flow did. expect(onClose).toHaveBeenCalled(); }); + + it("says the folder already has a lane instead of closing silently (#42)", async () => { + vi.mocked(api.lanes.ensure).mockResolvedValue({ + lane: laneFixture({ id: 3, kind: "adopted", status: "idle", title: "SG - Services" }), + created: false, + }); + const onAdded = vi.fn(); + const onClose = vi.fn(); + renderModal({ onAdded, onClose }); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Repo" })); + const dirField = screen.getByLabelText("Directory"); + await focusField(user, dirField); + await user.type(dirField, "/Users/tester/projects/repo"); + await user.click(screen.getByRole("button", { name: "Add lane" })); + + expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument(); + // The existing lane is selected so the user sees it, but nothing claims a new one. + expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); + expect(onClose).not.toHaveBeenCalled(); + }); }); describe("AddLaneModal — folder browse", () => { diff --git a/client/src/components/lanes/__tests__/LaneDiffView.test.tsx b/client/src/components/lanes/__tests__/LaneDiffView.test.tsx index 19d4a71f..1223aee8 100644 --- a/client/src/components/lanes/__tests__/LaneDiffView.test.tsx +++ b/client/src/components/lanes/__tests__/LaneDiffView.test.tsx @@ -72,3 +72,89 @@ describe("LaneDiffView — side by side", () => { expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto"); }); }); + +function fileWith(lines: NonNullable[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("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(); + 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(); + const marker = (row: HTMLElement) => + row.querySelector("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(); + const emph = [...container.querySelectorAll("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( + + ); + 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( + + ); + const rows = (id: string) => [ + ...screen.getByTestId(id).querySelectorAll("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)/); + }); +}); diff --git a/client/src/components/settings/AppearanceSettings.tsx b/client/src/components/settings/AppearanceSettings.tsx new file mode 100644 index 00000000..9f6585b3 --- /dev/null +++ b/client/src/components/settings/AppearanceSettings.tsx @@ -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ĩ + */ + +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 = { + 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 ( +
+
+
+ + +
+
+ const{" "} + x{" "} + ={" "} + "ok" +
+
+ + +
+
+
+ ); +} + +export function AppearanceSettings() { + const { t } = useTranslation("settings"); + const { mode, theme, themeFor, setMode, setThemeFor } = useTheme(); + + return ( +
+

+ + {t("appearance.title")} +

+

{t("appearance.description")}

+ +
+
+

{t("appearance.mode")}

+
+ {MODES.map(({ mode: m, Icon }) => ( + + ))} +
+
+ + {(["dark", "light"] as const).map((group) => ( +
+

+ {t(`appearance.${group}Themes`)} + {theme === group && ( + {t("appearance.active")} + )} +

+
+ {THEMES[group].map((name) => { + const selected = themeFor[group] === name; + const label = THEME_LABELS[name] ?? t("appearance.default"); + return ( + + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/client/src/components/settings/__tests__/AppearanceSettings.test.tsx b/client/src/components/settings/__tests__/AppearanceSettings.test.tsx new file mode 100644 index 00000000..a6f20a62 --- /dev/null +++ b/client/src/components/settings/__tests__/AppearanceSettings.test.tsx @@ -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 ``. + * @author Nguyễn Ngọc Trí Vĩ + */ + +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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/client/src/hooks/__tests__/useTheme.test.ts b/client/src/hooks/__tests__/useTheme.test.ts index bbdb073a..4c4ff023 100644 --- a/client/src/hooks/__tests__/useTheme.test.ts +++ b/client/src/hooks/__tests__/useTheme.test.ts @@ -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ĩ */ -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); + }); }); diff --git a/client/src/hooks/useTheme.ts b/client/src/hooks/useTheme.ts index 38633ee8..28046055 100644 --- a/client/src/hooks/useTheme.ts +++ b/client/src/hooks/useTheme.ts @@ -1,60 +1,135 @@ /** * @file useTheme.ts - * @description Dark/light mode state, backed by `localStorage` and the `dark` - * class on `` 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 ``; 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ĩ */ -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; -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; setTheme: (theme: Theme) => void; + setMode: (mode: ThemeMode) => void; + setThemeFor: (mode: Theme, name: string) => void; toggleTheme: () => void; } { - const [theme, setThemeState] = useState(() => 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, + }; } diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 5ad5d834..7a4a5882 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -34,6 +34,7 @@ "addLaneBranchPlaceholder": "feat/my-feature", "addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.", "addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.", + "addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.", "addLaneNotARepo": "Not a git repository (or no read access) yet.", "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.", diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index d7b6e0b9..9b533636 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -54,7 +54,8 @@ "theme": "Theme", "themeNames": { "dark": "Dark", - "light": "Light" + "light": "Light", + "system": "System" }, "throughput60s": "Throughput · last 60s", "topEventTypes": "Top event types", diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 109320f2..636ccb4c 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -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" } } diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index a0c5e289..7ac577b8 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -34,6 +34,7 @@ "addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi", "addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.", "addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.", + "addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.", "addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).", "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.", diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 91f2daba..dbf3046b 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -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", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index d80d9ef2..724aab8c 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -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" } } diff --git a/client/src/index.css b/client/src/index.css index 038ef244..9de7ef92 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -24,8 +24,11 @@ * the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to * RGB triplets for Tailwind's `` opacity support. */ -:root { +:root, +[data-mode="light"] { /* Light theme — the CSS default, so it applies with no class on . + `[data-mode="light"]` lets a light theme preview re-scope inside a dark + page (the Settings appearance cards). Radix `slate` light, RE-ORDERED so lightness rises with the index exactly as it does in dark: the page is the DARKEST step and a card is brighter than the page it sits on. The previous light block used the scale in its @@ -73,7 +76,9 @@ /* What every status/accent FILL is pre-mixed against (see `tint()` in tailwind.config.js) — the card surface, because that is where chips and alert strips overwhelmingly sit. */ - --tint-base: 245 243 236; /* = surface-3 */ + /* Re-declared by every scope that sets a theme (this block, `.dark`), so it + resolves against THAT scope's surface-3; a new scope must re-declare it too. */ + --tint-base: var(--surface-3); /* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible on a near-black code block and invisible on a light one. */ @@ -87,7 +92,7 @@ --syn-punctuation: 107 114 128; /* gray-500 #6b7280 */ --syn-property: 14 116 144; /* cyan-700 #0e7490 */ --syn-tag: 190 18 60; /* rose-700 #be123c */ - --syn-plain: 31 30 27; /* = fg-primary */ + --syn-plain: var(--fg-primary); --syn-add: 4 120 87; /* emerald-700 */ --syn-del: 185 28 28; /* red-700 #b91c1c */ } @@ -116,7 +121,7 @@ --status-success: 61 214 140; /* green-11 dark #3dd68c */ --status-danger: 255 149 146; /* red-11 dark #ff9592 */ --status-warning: 255 202 22; /* amber-11 dark #ffca16 */ - --tint-base: 39 42 45; /* = surface-3 */ + --tint-base: var(--surface-3); /* Unchanged from the pastels this file has always drawn code in. */ --syn-comment: 107 114 128; /* gray-500 #6b7280 */ --syn-string: 110 231 183; /* emerald-300 #6ee7b7 */ @@ -128,11 +133,177 @@ --syn-punctuation: 156 163 175; /* gray-400 #9ca3af */ --syn-property: 103 232 249; /* cyan-300 #67e8f9 */ --syn-tag: 253 164 175; /* rose-300 #fda4af */ - --syn-plain: 229 231 235; /* gray-200 #e5e7eb */ + --syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */ --syn-add: 110 231 183; /* emerald-300 */ --syn-del: 252 165 165; /* red-300 #fca5a5 */ } +/* Named themes (`data-theme` on , set by hooks/useTheme.ts, picked in + * Settings → Appearance). Each overrides only what differs from its mode's + * default block above; `--tint-base` and light `--syn-plain` are derived there. + * Dark ones pair with `.dark`, light ones with `[data-mode="light"]`, so a + * theme can never paint over the wrong mode whatever order the attributes + * land in. Contrast is held to the defaults' own bar: fg-muted ≥4.5:1 on a + * light card, status labels ≥4.5:1 on their own /20 chip in light themes. */ +.dark[data-theme="darcula"] { + --surface-0: 30 31 32; /* #1e1f20 */ + --surface-1: 35 36 37; /* #232425 */ + --surface-2: 39 41 42; /* #27292a */ + --surface-3: 43 43 43; /* #2b2b2b */ + --surface-4: 51 53 55; /* #333537 */ + --surface-5: 60 63 65; /* #3c3f41 */ + --border: 63 66 68; /* #3f4244 */ + --border-light: 77 81 83; /* #4d5153 */ + --accent: 53 146 196; /* #3592c4 */ + --accent-hover: 74 163 214; /* #4aa3d6 */ + --fg-primary: 220 220 220; /* #dcdcdc */ + --fg-secondary: 169 183 198; /* #a9b7c6 */ + --fg-muted: 140 146 150; /* #8c9296 */ + --status-success: 126 194 135; /* #7ec287 */ + --status-danger: 255 143 140; /* #ff8f8c */ + --status-warning: 232 191 106; /* #e8bf6a */ + --syn-comment: 128 128 128; /* #808080 */ + --syn-string: 127 159 105; /* #7f9f69 */ + --syn-number: 104 151 187; /* #6897bb */ + --syn-keyword: 204 120 50; /* #cc7832 */ + --syn-builtin: 136 136 198; /* #8888c6 */ + --syn-function: 255 198 109; /* #ffc66d */ + --syn-operator: 169 183 198; /* #a9b7c6 */ + --syn-punctuation: 169 183 198; /* #a9b7c6 */ + --syn-property: 168 139 192; /* #a88bc0 */ + --syn-tag: 232 191 106; /* #e8bf6a */ + --syn-add: 106 171 115; /* #6aab73 */ + --syn-del: 255 107 104; /* #ff6b68 */ + --syn-plain: 169 183 198; /* #a9b7c6 */ +} + +.dark[data-theme="apple-dark"] { + --surface-0: 22 22 24; /* #161618 */ + --surface-1: 28 28 30; /* #1c1c1e */ + --surface-2: 36 36 38; /* #242426 */ + --surface-3: 44 44 46; /* #2c2c2e */ + --surface-4: 54 54 56; /* #363638 */ + --surface-5: 58 58 60; /* #3a3a3c */ + --border: 56 56 58; /* #38383a */ + --border-light: 72 72 74; /* #48484a */ + --accent: 10 132 255; /* #0a84ff */ + --accent-hover: 64 156 255; /* #409cff */ + --fg-primary: 245 245 247; /* #f5f5f7 */ + --fg-secondary: 174 174 178; /* #aeaeb2 */ + --fg-muted: 142 142 147; /* #8e8e93 */ + --status-success: 48 209 88; /* #30d158 */ + --status-danger: 255 140 134; /* #ff8c86 */ + --status-warning: 255 214 10; /* #ffd60a */ + --syn-comment: 127 140 152; /* #7f8c98 */ + --syn-string: 252 106 93; /* #fc6a5d */ + --syn-number: 208 191 105; /* #d0bf69 */ + --syn-keyword: 252 95 163; /* #fc5fa3 */ + --syn-builtin: 93 216 255; /* #5dd8ff */ + --syn-function: 103 183 164; /* #67b7a4 */ + --syn-operator: 161 161 166; /* #a1a1a6 */ + --syn-punctuation: 161 161 166; /* #a1a1a6 */ + --syn-property: 181 140 240; /* #b58cf0 */ + --syn-tag: 252 95 163; /* #fc5fa3 */ + --syn-add: 48 209 88; /* #30d158 */ + --syn-del: 255 105 97; /* #ff6961 */ + --syn-plain: 229 229 234; /* #e5e5ea */ +} + +[data-mode="light"][data-theme="apple-light"] { + --surface-0: 233 233 238; /* #e9e9ee */ + --surface-1: 239 239 244; /* #efeff4 */ + --surface-2: 245 245 248; /* #f5f5f8 */ + --surface-3: 251 251 253; /* #fbfbfd */ + --surface-4: 253 253 254; /* #fdfdfe */ + --surface-5: 255 255 255; /* #ffffff */ + --border: 209 209 214; /* #d1d1d6 */ + --border-light: 229 229 234; /* #e5e5ea */ + --accent: 0 102 204; /* #0066cc */ + --accent-hover: 0 85 179; /* #0055b3 */ + --fg-primary: 29 29 31; /* #1d1d1f */ + --fg-secondary: 81 81 84; /* #515154 */ + --fg-muted: 110 110 115; /* #6e6e73 */ + --shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06); + --status-success: 23 107 46; /* #176b2e */ + --status-danger: 179 0 14; /* #b3000e */ + --status-warning: 122 79 0; /* #7a4f00 */ + --syn-comment: 93 108 121; /* #5d6c79 */ + --syn-string: 196 26 22; /* #c41a16 */ + --syn-number: 28 0 207; /* #1c00cf */ + --syn-keyword: 155 35 147; /* #9b2393 */ + --syn-builtin: 11 79 121; /* #0b4f79 */ + --syn-function: 50 109 116; /* #326d74 */ + --syn-operator: 75 75 80; /* #4b4b50 */ + --syn-punctuation: 75 75 80; /* #4b4b50 */ + --syn-property: 57 0 160; /* #3900a0 */ + --syn-tag: 155 35 147; /* #9b2393 */ + --syn-add: 31 122 54; /* #1f7a36 */ + --syn-del: 197 0 15; /* #c5000f */ +} + +[data-mode="light"][data-theme="intellij-light"] { + --surface-0: 236 238 242; /* #eceef2 */ + --surface-1: 241 242 245; /* #f1f2f5 */ + --surface-2: 247 248 250; /* #f7f8fa */ + --surface-3: 253 253 254; /* #fdfdfe */ + --surface-4: 255 255 255; /* #ffffff */ + --surface-5: 255 255 255; /* #ffffff */ + --border: 211 213 219; /* #d3d5db */ + --border-light: 228 230 235; /* #e4e6eb */ + --accent: 47 106 227; /* #2f6ae3 */ + --accent-hover: 39 88 192; /* #2758c0 */ + --fg-primary: 30 31 34; /* #1e1f22 */ + --fg-secondary: 73 75 87; /* #494b57 */ + --fg-muted: 108 112 126; /* #6c707e */ + --shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06); + --status-success: 23 106 45; /* #176a2d */ + --status-danger: 173 28 38; /* #ad1c26 */ + --status-warning: 124 82 0; /* #7c5200 */ + --syn-comment: 112 112 112; /* #707070 */ + --syn-string: 6 125 23; /* #067d17 */ + --syn-number: 23 80 235; /* #1750eb */ + --syn-keyword: 0 51 179; /* #0033b3 */ + --syn-builtin: 0 0 128; /* #000080 */ + --syn-function: 0 98 122; /* #00627a */ + --syn-operator: 75 75 75; /* #4b4b4b */ + --syn-punctuation: 75 75 75; /* #4b4b4b */ + --syn-property: 135 16 148; /* #871094 */ + --syn-tag: 0 51 179; /* #0033b3 */ + --syn-add: 6 125 23; /* #067d17 */ + --syn-del: 192 31 42; /* #c01f2a */ +} + +[data-mode="light"][data-theme="canvas"] { + --surface-0: 227 220 205; /* #e3dccd */ + --surface-1: 232 226 212; /* #e8e2d4 */ + --surface-2: 237 232 220; /* #ede8dc */ + --surface-3: 243 239 230; /* #f3efe6 */ + --surface-4: 247 244 237; /* #f7f4ed */ + --surface-5: 250 248 243; /* #faf8f3 */ + --border: 203 191 166; /* #cbbfa6 */ + --border-light: 220 211 192; /* #dcd3c0 */ + --accent: 122 82 48; /* #7a5230 */ + --accent-hover: 102 63 34; /* #663f22 */ + --fg-primary: 43 38 32; /* #2b2620 */ + --fg-secondary: 94 85 74; /* #5e554a */ + --fg-muted: 107 97 85; /* #6b6155 */ + --status-success: 54 93 36; /* #365d24 */ + --status-danger: 147 41 31; /* #93291f */ + --status-warning: 109 76 0; /* #6d4c00 */ + --syn-comment: 125 114 98; /* #7d7262 */ + --syn-string: 77 107 31; /* #4d6b1f */ + --syn-number: 154 91 0; /* #9a5b00 */ + --syn-keyword: 138 59 18; /* #8a3b12 */ + --syn-builtin: 47 94 110; /* #2f5e6e */ + --syn-function: 122 90 0; /* #7a5a00 */ + --syn-operator: 138 48 80; /* #8a3050 */ + --syn-punctuation: 107 97 85; /* #6b6155 */ + --syn-property: 59 102 96; /* #3b6660 */ + --syn-tag: 142 47 47; /* #8e2f2f */ + --syn-add: 77 107 31; /* #4d6b1f */ + --syn-del: 159 47 35; /* #9f2f23 */ +} + @layer base { * { scrollbar-width: thin; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index b0fc25ca..fdffdc44 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2297,7 +2297,14 @@ export const api = { * location isn't tied to a specific project (e.g. Sagi's mascot lane). * @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created. */ - ensure: (body: { cwd?: string; title?: string; pipeline?: string; tool?: LaneTool }) => + ensure: (body: { + cwd?: string; + title?: string; + pipeline?: string; + tool?: LaneTool; + /** Only a lane on THIS directory counts, not one on a parent of it. */ + exact?: boolean; + }) => request<{ lane: Lane; created: boolean }>("/lanes/ensure", { method: "POST", body: JSON.stringify(body), diff --git a/client/src/lib/highlight.ts b/client/src/lib/highlight.ts index c62981cb..52765cbc 100644 --- a/client/src/lib/highlight.ts +++ b/client/src/lib/highlight.ts @@ -1037,7 +1037,7 @@ export function highlight(source: string, lang: string): Token[] { * (`--syn-*` in index.css), not on fixed palette steps: the dark set is the * pastel one this file has always used, and light mode takes the same hues * four steps darker, because a pastel keyword on a light code block is - * unreadable. Diff rows keep their faint ±10% tint, which works on both. + * unreadable. Diff rows take a /20 status tint, pre-mixed per theme. */ export function tokenClass(type: TokenType): string { switch (type) { @@ -1068,9 +1068,9 @@ export function tokenClass(type: TokenType): string { case "boolean": return "text-syntax-number"; // literals grouped with numbers by colour case "diff-add": - return "text-syntax-add bg-emerald-500/10"; // green text + faint green row tint + return "text-syntax-add bg-status-success/20"; // green text + theme-aware green row tint case "diff-del": - return "text-syntax-del bg-red-500/10"; // red text + faint red row tint + return "text-syntax-del bg-status-danger/20"; // red text + theme-aware red row tint case "diff-meta": return "text-syntax-keyword"; case "plain": diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 6d82e856..149b023d 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -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 }, @@ -1080,6 +1083,8 @@ export function Settings() {
+ + {/* ─── MODEL PRICING ─── */}
diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 35f10173..a47079bc 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -6945,6 +6945,52 @@ exports[`screen snapshots > Settings 1`] = `
+
+
+

+ + + + + + + + Appearance +

+

+ Colour mode and theme. Saved in this browser only. +

+
+
+

+ Mode +

+
+ + + +
+
+
+

+ Dark themes + + · in use now + +

+
+ + + +
+
+
+

+ Light themes +

+
+ + + + +
+
+
+
` (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 `
` 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. `` 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. diff --git a/openapi.yaml b/openapi.yaml index 03636bf0..206e142b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: @@ -10633,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 diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index 8437904e..dc6996e9 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -1164,6 +1164,21 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () => assert.notEqual(sibling.body.lane.id, owner.body.lane.id); }); + it("with exact:true, creates a lane for a nested cwd instead of returning its parent (#42)", async () => { + const parent = path.join(ROOT, "ensure-exact-parent"); + const nested = path.join(parent, "services"); + const owner = await request("POST", "/api/lanes/ensure", { cwd: parent }); + assert.equal(owner.status, 201); + const r = await request("POST", "/api/lanes/ensure", { cwd: nested, exact: true }); + assert.equal(r.status, 201, JSON.stringify(r.body)); + assert.equal(r.body.created, true); + assert.equal(r.body.lane.cwd, nested); + // The same directory still answers with its own lane, never a second one. + const again = await request("POST", "/api/lanes/ensure", { cwd: nested, exact: true }); + assert.equal(again.body.created, false); + assert.equal(again.body.lane.id, r.body.lane.id); + }); + it("yields exactly one lane for two concurrent ensure calls on the same path", async () => { const cwd = path.join(ROOT, "ensure-concurrent"); const [a, b] = await Promise.all([ diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index 20a2fd4e..901dfa83 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -60,6 +60,11 @@ const schemas = { "Title for the lane if one has to be created; ignored when one already exists.", example: "App package", }, + exact: { + type: "boolean", + description: + "Only a lane on this very directory counts; a parent directory's lane is not returned, so a nested directory gets its own lane.", + }, }, }, LaneDestructiveActionRequest: { @@ -111,7 +116,7 @@ const paths = { tags: ["Lanes"], summary: "Find or adopt the lane owning a working directory", description: - "Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.", + "Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.", operationId: "ensureLane", requestBody: { required: true, diff --git a/server/routes/lanes.js b/server/routes/lanes.js index c3cbf4f7..07d1be84 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -318,7 +318,15 @@ router.post("/ensure", sameOriginGuard, (req, res) => { // already own this (cwd, tool) pair" is the real question, so a request for // a different tool than an existing lane at this cwd falls through to // creating that tool's own lane instead of adopting the wrong one. - const owner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); + // + // `exact: true` is the Add-lane modal's question — "is THIS directory a + // lane" — so a lane on a PARENT directory does not answer it (#42: the + // modal closed on the parent lane and no new card ever appeared). Without + // it, a nested cwd still resolves to its owner, which is what the CLI and + // other callers adopting "the lane I am inside" rely on. + const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); + const strip = (p) => p.replace(/\/+$/, ""); + const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null; if (owner) { // First-write wins on the title: an ensure-with-title on an existing lane // is how callers like Sagi adopt the lane, so don't clobber a real title