Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6353e88c9e | |||
| 6cf8a82889 | |||
| 2ea2184372 | |||
| aff863c69f | |||
| 764b967856 | |||
| 94ee2dc79c | |||
| 3f50e162e6 | |||
| 1f5c04e765 |
+3
-2
@@ -470,6 +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 mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
||||
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
|
||||
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
||||
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
||||
@@ -605,7 +606,7 @@ graph LR
|
||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` takes the keyboard on mount (`term.focus()`), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's `exit` frame to its owner through `onExit` — `LaneConsolePane` runs `newRun()` there, so a pane that ends (a double Ctrl+C out of `claude`, `exit`, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
||||
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
|
||||
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: an **Appearance** section first (mode + named theme, `localStorage` only), `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
|
||||
| `/*` | NotFound | None (static 404 page) |
|
||||
|
||||
### Activity Feed Interaction Model
|
||||
@@ -2396,7 +2397,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
|
||||
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
||||
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light toggle re-themes the app with one class flip rather than per-component `dark:` variants |
|
||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
|
||||
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
||||
| **Lucide React** | Tree-shakeable icon library. Only imports what's used (~20 icons) |
|
||||
| **TypeScript Strict** | Catches null/undefined bugs at compile time. `noUncheckedIndexedAccess` prevents array bounds issues |
|
||||
|
||||
@@ -64,6 +64,10 @@ Internal build — all rights reserved.
|
||||
`settings.json` stay read-only: they race with the live CLI.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark; light:
|
||||
Default, Apple Light, IntelliJ Light, Canvas. Each mode remembers its own
|
||||
theme, so the sidebar's quick toggle swaps between the two. Per-browser only.
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
|
||||
|
||||
+12
-2
@@ -52,7 +52,17 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
Other rules that fall out of this:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
|
||||
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
|
||||
- **React Router 6.28** - Client-side routing with nested layouts
|
||||
- **WebSocket** - Real-time event streaming from server
|
||||
- **Lucide Icons** - Modern, consistent icon set
|
||||
@@ -198,7 +208,7 @@ client/
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
|
||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||
│ │
|
||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||||
│ ├── App.tsx # Root component + router setup
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
var d =
|
||||
m === "light"
|
||||
? false
|
||||
: m === "system"
|
||||
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
: true;
|
||||
var r = document.documentElement;
|
||||
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
|
||||
@@ -174,7 +174,7 @@ export function DateTimePicker({
|
||||
? "bg-accent text-white font-medium"
|
||||
: isToday
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
@@ -201,7 +201,10 @@ export function DateTimePicker({
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
</span>
|
||||
{dateObj && (
|
||||
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
|
||||
<X
|
||||
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
|
||||
onClick={clearValue}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -216,7 +219,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -228,7 +231,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -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) {
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:theme")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{(["dark", "light"] as const).map((option) => {
|
||||
const active = option === theme;
|
||||
<div className="mt-2 grid grid-cols-3 gap-1">
|
||||
{(["dark", "light", "system"] as const).map((option) => {
|
||||
const active = option === mode;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setTheme(option)}
|
||||
onClick={() => setMode(option)}
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:themeNames.${option}`)}
|
||||
title={t(`nav:themeNames.${option}`)}
|
||||
@@ -563,8 +564,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
>
|
||||
{option === "dark" ? (
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
) : option === "light" ? (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file Sidebar.theme.test.tsx
|
||||
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
|
||||
function renderSidebar(collapsed = false) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Sidebar theme controls", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
|
||||
renderSidebar();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking System sets the mode and presses only System", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "System" }));
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking Light applies light mode to <html>", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "Light" }));
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem("theme", "light");
|
||||
renderSidebar(true);
|
||||
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
|
||||
expect(localStorage.getItem("theme")).toBe("dark");
|
||||
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -45,7 +45,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-white/15">
|
||||
<span className="rounded-sm bg-fg-primary/15">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
@@ -120,12 +120,21 @@ function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
|
||||
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not). */
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
if (type === "add") return "bg-status-success/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 }) {
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className="select-none text-fg-muted">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph.get(i)} />
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -72,3 +72,89 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
|
||||
});
|
||||
});
|
||||
|
||||
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
|
||||
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
|
||||
}
|
||||
|
||||
const ROWS = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
|
||||
]);
|
||||
|
||||
/** The unified row whose text contains `needle`. */
|
||||
function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
|
||||
r.textContent?.includes(needle)
|
||||
);
|
||||
if (!row) throw new Error(`no row for ${needle}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
expect(plus.className).toContain("text-status-success");
|
||||
expect(minus.textContent).toBe("-");
|
||||
expect(minus.className).toContain("text-status-danger");
|
||||
expect(ctx.className).toContain("text-fg-muted");
|
||||
expect(ctx.className).not.toContain("font-bold");
|
||||
});
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("does not emphasise a line that changed completely", () => {
|
||||
const { container } = render(
|
||||
<LaneDiffView
|
||||
file={fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
|
||||
])}
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
render(
|
||||
<LaneDiffView
|
||||
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
|
||||
layout="split"
|
||||
/>
|
||||
);
|
||||
const rows = (id: string) => [
|
||||
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
const [right] = rows("lane-diff-split-right");
|
||||
const [left] = rows("lane-diff-split-left");
|
||||
expect(right!.className).toContain("bg-status-success/20");
|
||||
expect(left!.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
* swaps the whole pane over to the new lane — its cwd, its tool, its history,
|
||||
* and its live tmux session — instead of leaving the previous lane's terminal
|
||||
* on screen under a new lane's header.
|
||||
*
|
||||
* A lane's pane is one tmux session, so starting or resuming into a lane
|
||||
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
|
||||
* that and attach to the still-live session instead of failing outright
|
||||
* (with a notice explaining what happened), since that session is still
|
||||
* reachable and the user did not ask to kill it. `killRun` is the other half:
|
||||
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
|
||||
* DOES want that previous session gone so a fresh start/resume can proceed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -246,12 +255,16 @@ export function LaneConsolePane({
|
||||
resumeLast: args.resumeLast,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
||||
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
||||
// A code-only check: the server's message is human prose ("lane
|
||||
// already has a live run"), never the literal strings "409" or
|
||||
// "ERUNLIVE" — matching against `.message` here is dead code that
|
||||
// always falls through to the raw error below.
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
await attachToRun(updatedLane.run_id);
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -307,13 +320,34 @@ export function LaneConsolePane({
|
||||
onLaneCreated(ensureResult.lane);
|
||||
}
|
||||
|
||||
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
// The lane's own pane is still busy with a PREVIOUS live session —
|
||||
// that session is still active and reachable, so land on it
|
||||
// instead of failing outright. Killing it (Active runs → Kill) is
|
||||
// what actually frees the lane for this resume. Checked by `.code`
|
||||
// — the server's `.message` is prose, never the literal string
|
||||
// "ERUNLIVE" or "409".
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
onLaneIdChange(targetLaneId);
|
||||
await attachToRun(updatedLane.run_id);
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
throw new Error("No run_id returned from lane start");
|
||||
@@ -343,7 +377,7 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
@@ -360,6 +394,28 @@ export function LaneConsolePane({
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Frees a lane blocked by a live run nobody wants anymore (see Active
|
||||
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
|
||||
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
|
||||
const killRun = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy("kill");
|
||||
setError(null);
|
||||
try {
|
||||
await api.run.kill(id);
|
||||
if (handle?.id === id) newRun();
|
||||
refreshList();
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[handle, newRun, refreshList, t]
|
||||
);
|
||||
|
||||
if (laneId === null && showLaneSelector) {
|
||||
return (
|
||||
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
@@ -438,6 +494,7 @@ export function LaneConsolePane({
|
||||
externalSessions={externalSessions}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onKill={killRun}
|
||||
onRefresh={refreshList}
|
||||
/>
|
||||
</header>
|
||||
|
||||
@@ -10,18 +10,23 @@
|
||||
* - `RunsModal` — the merged list itself: live in-memory handles from
|
||||
* `activeRuns` deduped against persistent `runHistory`, newest first, with
|
||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||
* Resume / View actions.
|
||||
* Resume / View / Kill actions.
|
||||
*
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd.
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||
* from them for the same reason.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns`,
|
||||
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||
* just calls `onRefresh`.
|
||||
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
|
||||
* runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
|
||||
* discarding a file in the Changes panel) is what actually frees a lane whose
|
||||
* pane is still busy with a previous session — `LaneConsolePane` needs it
|
||||
* because starting or resuming into a lane already holding a live run answers
|
||||
* 409 ERUNLIVE rather than overwriting a prompt in flight.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -39,6 +44,7 @@ import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions = [],
|
||||
onResumeFromHistory,
|
||||
onViewFromHistory,
|
||||
onKill,
|
||||
onRefresh,
|
||||
}: {
|
||||
activeRuns: RunListResponse | null;
|
||||
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions?: Session[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
|
||||
* spawned rows only — an external session has no tmux session here to
|
||||
* kill. */
|
||||
onKill: (id: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
|
||||
setOpen(false);
|
||||
onViewFromHistory(item);
|
||||
}}
|
||||
onKill={onKill}
|
||||
runHistory={historyItems}
|
||||
onClose={() => setOpen(false)}
|
||||
onRefresh={onRefresh}
|
||||
@@ -273,6 +285,7 @@ export function RunsModal({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
runHistory,
|
||||
onClose,
|
||||
onRefresh,
|
||||
@@ -282,6 +295,7 @@ export function RunsModal({
|
||||
onAttach: (id: string) => void;
|
||||
onResume: (item: DashboardRunHistoryItem) => void;
|
||||
onView: (item: DashboardRunHistoryItem) => void;
|
||||
onKill: (id: string) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
@@ -446,6 +460,7 @@ export function RunsModal({
|
||||
const h = historyById.get(r.id);
|
||||
if (h) onView(h);
|
||||
}}
|
||||
onKill={() => onKill(r.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -512,14 +527,20 @@ function UnifiedRunRowView({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
}: {
|
||||
row: UnifiedRunRow;
|
||||
isCurrent: boolean;
|
||||
onAttach: () => void;
|
||||
onResume: () => void;
|
||||
onView: () => void;
|
||||
onKill: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
// Two clicks before the tmux session actually dies — same convention as
|
||||
// discarding a file's changes in the Changes panel, for the one action
|
||||
// here that cannot be undone.
|
||||
const [confirmKill, setConfirmKill] = useState(false);
|
||||
const startedDate = new Date(row.startedAt);
|
||||
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
|
||||
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
|
||||
@@ -593,6 +614,32 @@ function UnifiedRunRowView({
|
||||
{t("runs.viewLabel", "View")}
|
||||
</button>
|
||||
)}
|
||||
{row.isLive && !row.external && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirmKill) {
|
||||
setConfirmKill(true);
|
||||
return;
|
||||
}
|
||||
setConfirmKill(false);
|
||||
onKill();
|
||||
}}
|
||||
onBlur={() => setConfirmKill(false)}
|
||||
title={
|
||||
confirmKill
|
||||
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
|
||||
: t("runs.killLabel", "Kill")
|
||||
}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
|
||||
confirmKill
|
||||
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
|
||||
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
|
||||
}`}
|
||||
>
|
||||
<Square className="w-3 h-3" />
|
||||
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{row.promptPreview && (
|
||||
|
||||
@@ -77,7 +77,9 @@ interface PromptEditorProps {
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
||||
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
@@ -266,6 +267,64 @@ describe("LaneConsolePane", () => {
|
||||
await waitFor(() => expect(prompt.value).toBe("add a button"));
|
||||
});
|
||||
|
||||
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
|
||||
const HISTORY_ITEM = {
|
||||
id: "run-old",
|
||||
session_id: "sess-old",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
resume_session_id: null,
|
||||
prompt_preview: "earlier prompt",
|
||||
status: "abandoned" as const,
|
||||
exit_code: null,
|
||||
started_at: new Date(1000).toISOString(),
|
||||
ended_at: new Date(2000).toISOString(),
|
||||
isLive: false,
|
||||
tool: "claude" as const,
|
||||
};
|
||||
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
// Matches the real shape `request()` throws: a plain, human-readable
|
||||
// `.message` plus a `.code` a caller actually branches on.
|
||||
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
|
||||
);
|
||||
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lanes: [{ ...LANE, run_id: "run-live" }],
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-live",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane {...baseProps()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
|
||||
const resumeButtons = await screen.findAllByRole("button", {
|
||||
name: "Resume existing session",
|
||||
});
|
||||
fireEvent.click(resumeButtons[0]!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
|
||||
);
|
||||
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
||||
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
||||
|
||||
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
|
||||
onAttach: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onViewFromHistory: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
@@ -127,6 +128,7 @@ function renderModal(
|
||||
onAttach: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
@@ -274,6 +276,19 @@ describe("RunsModal", () => {
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
});
|
||||
|
||||
it("needs two clicks to kill a live row, and never offers Kill for an external one", () => {
|
||||
const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
|
||||
const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill"));
|
||||
fireEvent.click(killButton);
|
||||
expect(spies.onKill).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?")));
|
||||
expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID);
|
||||
unmount();
|
||||
|
||||
renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]);
|
||||
expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull();
|
||||
});
|
||||
|
||||
it("fires resume with the history item behind a finished row", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance: the Dark / Light / System mode switch and
|
||||
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, Palette, Sun } from "lucide-react";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
"intellij-light": "IntelliJ Light",
|
||||
canvas: "Canvas",
|
||||
};
|
||||
|
||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "dark", Icon: Moon },
|
||||
{ mode: "light", Icon: Sun },
|
||||
{ mode: "system", Icon: Monitor },
|
||||
];
|
||||
|
||||
function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
|
||||
data-mode={mode}
|
||||
data-theme={name === "default" ? undefined : name}
|
||||
>
|
||||
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-accent" />
|
||||
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] leading-tight">
|
||||
<span className="text-syntax-keyword">const</span>{" "}
|
||||
<span className="text-syntax-function">x</span>{" "}
|
||||
<span className="text-syntax-operator">=</span>{" "}
|
||||
<span className="text-syntax-string">"ok"</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
|
||||
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
|
||||
return (
|
||||
<section id="appearance" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Palette className="w-4 h-4 text-fg-muted" />
|
||||
{t("appearance.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
|
||||
{MODES.map(({ mode: m, Icon }) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
aria-pressed={mode === m}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === m
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(`appearance.modes.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(["dark", "light"] as const).map((group) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t(`appearance.${group}Themes`)}
|
||||
{theme === group && (
|
||||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AppearanceSettings } from "../AppearanceSettings";
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
describe("AppearanceSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
root.classList.remove("dark");
|
||||
root.removeAttribute("data-theme");
|
||||
root.removeAttribute("data-mode");
|
||||
});
|
||||
|
||||
it("renders every theme of both modes, defaults selected", () => {
|
||||
render(<AppearanceSettings />);
|
||||
const pressed = screen
|
||||
.getAllByRole("button", { pressed: true })
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
|
||||
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("picking a dark theme applies it at once; a light pick waits for light mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("button", { name: /Darcula/ }));
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /Canvas/ }));
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /^Light$/ }));
|
||||
expect(root.classList.contains("dark")).toBe(false);
|
||||
expect(root.dataset.theme).toBe("canvas");
|
||||
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("the System mode button is pressed after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("button", { name: /System/ }));
|
||||
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
});
|
||||
|
||||
it("each swatch scopes its own mode and theme", () => {
|
||||
const { container } = render(<AppearanceSettings />);
|
||||
expect(container.querySelector('.dark[data-theme="darcula"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
|
||||
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
|
||||
>
|
||||
{/* Depth connector line */}
|
||||
@@ -252,7 +252,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
{events.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
|
||||
>
|
||||
{/* Tool pill */}
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
|
||||
{recent.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
|
||||
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
|
||||
>
|
||||
{/* Event type badge */}
|
||||
<span
|
||||
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(s.id)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
|
||||
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
|
||||
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
|
||||
</button>
|
||||
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.close")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @file useTheme.test.ts
|
||||
* @description Tests for useTheme — the dark/light mode hook.
|
||||
* @description Tests for useTheme — the dark/light/system mode and per-mode named-theme hook.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTheme } from "../useTheme";
|
||||
|
||||
@@ -12,9 +12,12 @@ describe("useTheme", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
document.documentElement.removeAttribute("data-mode");
|
||||
});
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("defaults to dark when localStorage is empty", () => {
|
||||
@@ -46,4 +49,165 @@ describe("useTheme", () => {
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("system mode follows prefers-color-scheme, live", () => {
|
||||
let dark = true;
|
||||
const listeners = new Set<() => void>();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
get matches() {
|
||||
return dark;
|
||||
},
|
||||
media: query,
|
||||
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
||||
}));
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setMode("system"));
|
||||
expect(result.current.mode).toBe("system");
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
act(() => {
|
||||
dark = false;
|
||||
listeners.forEach((fn) => fn());
|
||||
});
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
expect(document.documentElement.dataset.mode).toBe("light");
|
||||
});
|
||||
|
||||
it("system mode without matchMedia falls back to dark", () => {
|
||||
vi.stubGlobal("matchMedia", undefined);
|
||||
localStorage.setItem("theme", "system");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("each mode remembers its own named theme", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("light", "canvas"));
|
||||
expect(document.documentElement.dataset.theme).toBe("darcula");
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("canvas");
|
||||
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "canvas" });
|
||||
});
|
||||
|
||||
it("the default theme carries no data-theme attribute", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("dark", "default"));
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("an unknown stored theme id falls back to default", () => {
|
||||
localStorage.setItem("agenthub.theme.dark", "no-such-theme");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.themeFor.dark).toBe("default");
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("a light theme id is never applied in dark mode", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "canvas"));
|
||||
expect(result.current.themeFor.dark).toBe("default");
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("two hook instances stay in sync", () => {
|
||||
const a = renderHook(() => useTheme());
|
||||
const b = renderHook(() => useTheme());
|
||||
act(() => a.result.current.setTheme("light"));
|
||||
act(() => a.result.current.setThemeFor("light", "apple-light"));
|
||||
expect(b.result.current.theme).toBe("light");
|
||||
expect(b.result.current.themeFor.light).toBe("apple-light");
|
||||
});
|
||||
|
||||
it("another tab's storage write updates a mounted hook", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
act(() => {
|
||||
localStorage.setItem("theme", "light");
|
||||
localStorage.setItem("agenthub.theme.light", "canvas");
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "theme" }));
|
||||
});
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("canvas");
|
||||
expect(document.documentElement.dataset.mode).toBe("light");
|
||||
});
|
||||
|
||||
it("disabled storage (throwing) falls back to dark/default and setters do not crash", () => {
|
||||
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("SecurityError");
|
||||
});
|
||||
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(result.current.mode).toBe("dark");
|
||||
expect(result.current.themeFor).toEqual({ dark: "default", light: "default" });
|
||||
expect(() => act(() => result.current.setMode("light"))).not.toThrow();
|
||||
expect(() => act(() => result.current.setThemeFor("dark", "darcula"))).not.toThrow();
|
||||
expect(() => act(() => result.current.toggleTheme())).not.toThrow();
|
||||
expect(result.current.theme).toBe("dark");
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
set.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("setThemeFor with an unknown id keeps the previous pick", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("dark", "no-such-theme"));
|
||||
act(() => result.current.setThemeFor("light", ""));
|
||||
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "default" });
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
|
||||
expect(localStorage.getItem("agenthub.theme.light")).toBeNull();
|
||||
expect(document.documentElement.dataset.theme).toBe("darcula");
|
||||
});
|
||||
|
||||
it("an unknown stored mode falls back to dark", () => {
|
||||
localStorage.setItem("theme", "blue");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.mode).toBe("dark");
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.mode).toBe("dark");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, "light"],
|
||||
[false, "dark"],
|
||||
])("toggle from system (OS dark=%s) sets the explicit opposite: %s", (osDark, expected) => {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: osDark,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
localStorage.setItem("theme", "system");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.mode).toBe("system");
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(result.current.mode).toBe(expected);
|
||||
expect(result.current.theme).toBe(expected);
|
||||
expect(localStorage.getItem("theme")).toBe(expected);
|
||||
});
|
||||
|
||||
it("unmount removes the matchMedia listener", () => {
|
||||
const listeners = new Set<() => void>();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
||||
}));
|
||||
const { unmount } = renderHook(() => useTheme());
|
||||
expect(listeners.size).toBeGreaterThan(0);
|
||||
unmount();
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,135 @@
|
||||
/**
|
||||
* @file useTheme.ts
|
||||
* @description Dark/light mode state, backed by `localStorage` and the `dark`
|
||||
* class on `<html>` that Tailwind's `darkMode: "class"` reads. Default is
|
||||
* dark; there is no `prefers-color-scheme` fallback.
|
||||
* @description Appearance state: a mode (`dark` | `light` | `system`) plus one
|
||||
* named theme remembered PER mode, all backed by `localStorage`. Applies the
|
||||
* `dark` class Tailwind's `darkMode: "class"` reads, `data-mode`, and a
|
||||
* `data-theme` attribute (absent for `default`) on `<html>`; the palettes
|
||||
* themselves are CSS-variable blocks in `index.css`. State is never cached in
|
||||
* the module — every instance reads storage + `matchMedia` through
|
||||
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
|
||||
* another tab's change arrives through the `storage` event.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type ThemeMode = Theme | "system";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark"],
|
||||
light: ["default", "apple-light", "intellij-light", "canvas"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
const MODE_KEY = "theme";
|
||||
const themeKey = (mode: Theme) => `agenthub.theme.${mode}`;
|
||||
const CHANGE_EVENT = "agenthub-theme";
|
||||
const SYSTEM_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return "dark";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredTheme(theme: Theme): void {
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme): void {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
function systemQuery(): MediaQueryList | undefined {
|
||||
return typeof window.matchMedia === "function" ? window.matchMedia(SYSTEM_QUERY) : undefined;
|
||||
}
|
||||
|
||||
function readMode(): ThemeMode {
|
||||
const v = read(MODE_KEY);
|
||||
return v === "light" || v === "system" ? v : "dark";
|
||||
}
|
||||
|
||||
function readThemeFor(mode: Theme): string {
|
||||
const v = read(themeKey(mode));
|
||||
return v && (THEMES[mode] as readonly string[]).includes(v) ? v : "default";
|
||||
}
|
||||
|
||||
// A primitive snapshot: stable by value, so useSyncExternalStore never loops.
|
||||
function getSnapshot(): string {
|
||||
const mode = readMode();
|
||||
const resolved: Theme =
|
||||
mode === "system" ? (systemQuery()?.matches === false ? "light" : "dark") : mode;
|
||||
return [mode, resolved, readThemeFor("dark"), readThemeFor("light")].join("|");
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
const mq = systemQuery();
|
||||
window.addEventListener(CHANGE_EVENT, onChange);
|
||||
window.addEventListener("storage", onChange);
|
||||
mq?.addEventListener?.("change", onChange);
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange);
|
||||
window.removeEventListener("storage", onChange);
|
||||
mq?.removeEventListener?.("change", onChange);
|
||||
};
|
||||
}
|
||||
|
||||
function apply(resolved: Theme, name: string): void {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", resolved === "dark");
|
||||
root.dataset.mode = resolved;
|
||||
if (name === "default") root.removeAttribute("data-theme");
|
||||
else root.dataset.theme = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write the dashboard's active color theme.
|
||||
* @returns the current theme, a setter for an explicit value, and a toggle.
|
||||
* Read/write the dashboard's appearance.
|
||||
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
|
||||
* the user picked; `themeFor` — the named theme each mode remembers; setters
|
||||
* for each, and `toggleTheme`, which sets the explicit opposite of `theme`.
|
||||
*/
|
||||
export function useTheme(): {
|
||||
theme: Theme;
|
||||
mode: ThemeMode;
|
||||
themeFor: Record<Theme, string>;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
setThemeFor: (mode: Theme, name: string) => void;
|
||||
toggleTheme: () => void;
|
||||
} {
|
||||
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
|
||||
const [mode, resolved, dark, light] = useSyncExternalStore(subscribe, getSnapshot).split("|") as [
|
||||
ThemeMode,
|
||||
Theme,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const active = resolved === "dark" ? dark : light;
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
useLayoutEffect(() => {
|
||||
apply(resolved, active);
|
||||
}, [resolved, active]);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
writeStoredTheme(next);
|
||||
setThemeState(next);
|
||||
const setMode = useCallback((next: ThemeMode) => write(MODE_KEY, next), []);
|
||||
|
||||
const setThemeFor = useCallback((forMode: Theme, name: string) => {
|
||||
if ((THEMES[forMode] as readonly string[]).includes(name)) write(themeKey(forMode), name);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
setMode(resolved === "dark" ? "light" : "dark");
|
||||
}, [resolved, setMode]);
|
||||
|
||||
return { theme, setTheme, toggleTheme };
|
||||
return {
|
||||
theme: resolved,
|
||||
mode,
|
||||
themeFor: { dark, light },
|
||||
setTheme: setMode,
|
||||
setMode,
|
||||
setThemeFor,
|
||||
toggleTheme,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"theme": "Theme",
|
||||
"themeNames": {
|
||||
"dark": "Dark",
|
||||
"light": "Light"
|
||||
"light": "Light",
|
||||
"system": "System"
|
||||
},
|
||||
"throughput60s": "Throughput · last 60s",
|
||||
"topEventTypes": "Top event types",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"attachFailed": "Couldn't attach to run: {{message}}",
|
||||
"attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
|
||||
"cwdRequired": "Working directory required to start a run",
|
||||
"killFailed": "Couldn't stop: {{message}}",
|
||||
"laneCreateFailed": "Failed to create lane: {{message}}",
|
||||
@@ -97,7 +98,7 @@
|
||||
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
|
||||
"sandboxWarning": "Codex will run commands with no sandbox.",
|
||||
"shortcut": "Cmd+Enter / Ctrl+Enter to send"
|
||||
"shortcut": "Enter to send · Shift+Enter for newline"
|
||||
},
|
||||
"limitations": {
|
||||
"dismiss": "Got it"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"attachFailed": "Không gắn được vào run: {{message}}",
|
||||
"attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
|
||||
"cwdRequired": "Cần thư mục làm việc để bắt đầu run",
|
||||
"killFailed": "Không dừng được: {{message}}",
|
||||
"laneCreateFailed": "Không thể tạo làn đường: {{message}}",
|
||||
@@ -96,7 +97,7 @@
|
||||
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
|
||||
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
|
||||
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
|
||||
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
||||
},
|
||||
"limitations": {
|
||||
"dismiss": "Đã rõ"
|
||||
|
||||
@@ -495,5 +495,19 @@
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "Không trả lời ({{status}}): {{message}}"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
|
||||
"mode": "Chế độ",
|
||||
"modes": {
|
||||
"dark": "Tối",
|
||||
"light": "Sáng",
|
||||
"system": "Theo hệ thống"
|
||||
},
|
||||
"darkThemes": "Theme tối",
|
||||
"lightThemes": "Theme sáng",
|
||||
"active": "· đang dùng",
|
||||
"default": "Mặc định"
|
||||
}
|
||||
}
|
||||
|
||||
+261
-24
@@ -15,35 +15,86 @@
|
||||
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
|
||||
* 3-5 component background 9-10 solid (buttons, vibrant fills)
|
||||
* 11-12 text (11 secondary, 12 primary)
|
||||
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/
|
||||
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
|
||||
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
|
||||
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
|
||||
* and terracotta, because a cool near-white page reads as glare. `green`/
|
||||
* `red`/`amber` step 11 supplies the status tokens — the same step used for
|
||||
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
|
||||
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
|
||||
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
|
||||
*/
|
||||
:root {
|
||||
:root,
|
||||
[data-mode="light"] {
|
||||
/* Light theme — the CSS default, so it applies with no class on <html>.
|
||||
Radix `slate` (light), steps 1/2/3/4/5/6/7. */
|
||||
--surface-0: 252 252 253; /* slate-1 #fcfcfd */
|
||||
--surface-1: 249 249 251; /* slate-2 #f9f9fb */
|
||||
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */
|
||||
--surface-3: 232 232 236; /* slate-4 #e8e8ec */
|
||||
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */
|
||||
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
|
||||
--border: 217 217 224; /* slate-6 #d9d9e0 */
|
||||
--border-light: 205 206 214; /* slate-7 #cdced6 */
|
||||
--accent: 0 144 255; /* blue-9 #0090ff */
|
||||
--accent-hover: 5 136 240; /* blue-10 #0588f0 */
|
||||
--fg-primary: 28 32 36; /* slate-12 #1c2024 */
|
||||
--fg-secondary: 96 100 108; /* slate-11 #60646c */
|
||||
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */
|
||||
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
|
||||
/* Status: green/red/amber step 11 (light) — the same "readable text" step
|
||||
as fg-secondary, so success/danger/warning sit at the same visual weight
|
||||
as ordinary secondary text, not shouting over it. */
|
||||
--status-success: 33 131 88; /* green-11 #218358 */
|
||||
--status-danger: 206 44 49; /* red-11 #ce2c31 */
|
||||
--status-warning: 171 100 0; /* amber-11 #ab6400 */
|
||||
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
|
||||
page (the Settings appearance cards).
|
||||
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
|
||||
as it does in dark: the page is the DARKEST step and a card is brighter
|
||||
than the page it sits on. The previous light block used the scale in its
|
||||
native direction, which inverted the whole language — a near-white page
|
||||
(glare) under grey cards that read as recessed, and every `hover:bg-*`
|
||||
darkening instead of lifting.
|
||||
|
||||
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
|
||||
A cool near-white ramp kept reading as glare no matter how far the page
|
||||
step was pulled down, because the brightest thing on screen was also the
|
||||
bluest. Cream lowers the perceived glare at the same measured lightness
|
||||
and gives every neutral a warm cast to sit against. The whole ramp sits
|
||||
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
|
||||
white: at full brightness a large light panel still tires the eye. */
|
||||
--surface-0: 232 229 218; /* #e8e5da — page background */
|
||||
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
|
||||
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
|
||||
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
|
||||
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
|
||||
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
|
||||
--border: 207 201 184; /* #cfc9b8 */
|
||||
--border-light: 222 217 204; /* #ded9cc */
|
||||
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
|
||||
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
|
||||
fails as a link or a button label. This is 5.3:1 as text on a card and
|
||||
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
|
||||
onto a near-black page is a different problem with a different answer. */
|
||||
--accent: 163 74 40; /* #a34a28 */
|
||||
--accent-hover: 142 63 32; /* #8e3f20 */
|
||||
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
|
||||
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
|
||||
/* Measured, not eyeballed: this carries real content (timestamps, counts),
|
||||
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
|
||||
sits a clear step below fg-secondary. */
|
||||
--fg-muted: 104 99 89; /* #686359 */
|
||||
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
|
||||
over cream. The SHADOW carries "raised" here, not the quiet border. */
|
||||
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
|
||||
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
|
||||
the card; these also clear it against the status's OWN densest chip (a
|
||||
20% tint of itself — 4.5:1), which is where the label actually sits. */
|
||||
--status-success: 22 104 66; /* #166842 */
|
||||
--status-danger: 170 36 41; /* #aa2429 */
|
||||
--status-warning: 133 79 0; /* #854f00 */
|
||||
/* What every status/accent FILL is pre-mixed against (see `tint()` in
|
||||
tailwind.config.js) — the card surface, because that is where chips and
|
||||
alert strips overwhelmingly sit. */
|
||||
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
|
||||
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
|
||||
--tint-base: var(--surface-3);
|
||||
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
|
||||
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
|
||||
on a near-black code block and invisible on a light one. */
|
||||
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-string: 4 120 87; /* emerald-700 #047857 */
|
||||
--syn-number: 194 65 12; /* orange-700 #c2410c */
|
||||
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
|
||||
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
|
||||
--syn-function: 180 83 9; /* amber-700 #b45309 */
|
||||
--syn-operator: 190 24 93; /* pink-700 #be185d */
|
||||
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
|
||||
--syn-tag: 190 18 60; /* rose-700 #be123c */
|
||||
--syn-plain: var(--fg-primary);
|
||||
--syn-add: 4 120 87; /* emerald-700 */
|
||||
--syn-del: 185 28 28; /* red-700 #b91c1c */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -70,6 +121,187 @@
|
||||
--status-success: 61 214 140; /* green-11 dark #3dd68c */
|
||||
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
|
||||
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
|
||||
--tint-base: var(--surface-3);
|
||||
/* Unchanged from the pastels this file has always drawn code in. */
|
||||
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
|
||||
--syn-number: 253 186 116; /* orange-300 #fdba74 */
|
||||
--syn-keyword: 196 181 253; /* violet-300 #c4b5fd */
|
||||
--syn-builtin: 125 211 252; /* sky-300 #7dd3fc */
|
||||
--syn-function: 254 240 138; /* yellow-200 #fef08a */
|
||||
--syn-operator: 249 168 212; /* pink-300 #f9a8d4 */
|
||||
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
|
||||
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
|
||||
--syn-tag: 253 164 175; /* rose-300 #fda4af */
|
||||
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
|
||||
--syn-add: 110 231 183; /* emerald-300 */
|
||||
--syn-del: 252 165 165; /* red-300 #fca5a5 */
|
||||
}
|
||||
|
||||
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
|
||||
* Settings → Appearance). Each overrides only what differs from its mode's
|
||||
* default block above; `--tint-base` and light `--syn-plain` are derived there.
|
||||
* Dark ones pair with `.dark`, light ones with `[data-mode="light"]`, so a
|
||||
* theme can never paint over the wrong mode whatever order the attributes
|
||||
* land in. Contrast is held to the defaults' own bar: fg-muted ≥4.5:1 on a
|
||||
* light card, status labels ≥4.5:1 on their own /20 chip in light themes. */
|
||||
.dark[data-theme="darcula"] {
|
||||
--surface-0: 30 31 32; /* #1e1f20 */
|
||||
--surface-1: 35 36 37; /* #232425 */
|
||||
--surface-2: 39 41 42; /* #27292a */
|
||||
--surface-3: 43 43 43; /* #2b2b2b */
|
||||
--surface-4: 51 53 55; /* #333537 */
|
||||
--surface-5: 60 63 65; /* #3c3f41 */
|
||||
--border: 63 66 68; /* #3f4244 */
|
||||
--border-light: 77 81 83; /* #4d5153 */
|
||||
--accent: 53 146 196; /* #3592c4 */
|
||||
--accent-hover: 74 163 214; /* #4aa3d6 */
|
||||
--fg-primary: 220 220 220; /* #dcdcdc */
|
||||
--fg-secondary: 169 183 198; /* #a9b7c6 */
|
||||
--fg-muted: 140 146 150; /* #8c9296 */
|
||||
--status-success: 126 194 135; /* #7ec287 */
|
||||
--status-danger: 255 143 140; /* #ff8f8c */
|
||||
--status-warning: 232 191 106; /* #e8bf6a */
|
||||
--syn-comment: 128 128 128; /* #808080 */
|
||||
--syn-string: 127 159 105; /* #7f9f69 */
|
||||
--syn-number: 104 151 187; /* #6897bb */
|
||||
--syn-keyword: 204 120 50; /* #cc7832 */
|
||||
--syn-builtin: 136 136 198; /* #8888c6 */
|
||||
--syn-function: 255 198 109; /* #ffc66d */
|
||||
--syn-operator: 169 183 198; /* #a9b7c6 */
|
||||
--syn-punctuation: 169 183 198; /* #a9b7c6 */
|
||||
--syn-property: 168 139 192; /* #a88bc0 */
|
||||
--syn-tag: 232 191 106; /* #e8bf6a */
|
||||
--syn-add: 106 171 115; /* #6aab73 */
|
||||
--syn-del: 255 107 104; /* #ff6b68 */
|
||||
--syn-plain: 169 183 198; /* #a9b7c6 */
|
||||
}
|
||||
|
||||
.dark[data-theme="apple-dark"] {
|
||||
--surface-0: 22 22 24; /* #161618 */
|
||||
--surface-1: 28 28 30; /* #1c1c1e */
|
||||
--surface-2: 36 36 38; /* #242426 */
|
||||
--surface-3: 44 44 46; /* #2c2c2e */
|
||||
--surface-4: 54 54 56; /* #363638 */
|
||||
--surface-5: 58 58 60; /* #3a3a3c */
|
||||
--border: 56 56 58; /* #38383a */
|
||||
--border-light: 72 72 74; /* #48484a */
|
||||
--accent: 10 132 255; /* #0a84ff */
|
||||
--accent-hover: 64 156 255; /* #409cff */
|
||||
--fg-primary: 245 245 247; /* #f5f5f7 */
|
||||
--fg-secondary: 174 174 178; /* #aeaeb2 */
|
||||
--fg-muted: 142 142 147; /* #8e8e93 */
|
||||
--status-success: 48 209 88; /* #30d158 */
|
||||
--status-danger: 255 140 134; /* #ff8c86 */
|
||||
--status-warning: 255 214 10; /* #ffd60a */
|
||||
--syn-comment: 127 140 152; /* #7f8c98 */
|
||||
--syn-string: 252 106 93; /* #fc6a5d */
|
||||
--syn-number: 208 191 105; /* #d0bf69 */
|
||||
--syn-keyword: 252 95 163; /* #fc5fa3 */
|
||||
--syn-builtin: 93 216 255; /* #5dd8ff */
|
||||
--syn-function: 103 183 164; /* #67b7a4 */
|
||||
--syn-operator: 161 161 166; /* #a1a1a6 */
|
||||
--syn-punctuation: 161 161 166; /* #a1a1a6 */
|
||||
--syn-property: 181 140 240; /* #b58cf0 */
|
||||
--syn-tag: 252 95 163; /* #fc5fa3 */
|
||||
--syn-add: 48 209 88; /* #30d158 */
|
||||
--syn-del: 255 105 97; /* #ff6961 */
|
||||
--syn-plain: 229 229 234; /* #e5e5ea */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="apple-light"] {
|
||||
--surface-0: 233 233 238; /* #e9e9ee */
|
||||
--surface-1: 239 239 244; /* #efeff4 */
|
||||
--surface-2: 245 245 248; /* #f5f5f8 */
|
||||
--surface-3: 251 251 253; /* #fbfbfd */
|
||||
--surface-4: 253 253 254; /* #fdfdfe */
|
||||
--surface-5: 255 255 255; /* #ffffff */
|
||||
--border: 209 209 214; /* #d1d1d6 */
|
||||
--border-light: 229 229 234; /* #e5e5ea */
|
||||
--accent: 0 102 204; /* #0066cc */
|
||||
--accent-hover: 0 85 179; /* #0055b3 */
|
||||
--fg-primary: 29 29 31; /* #1d1d1f */
|
||||
--fg-secondary: 81 81 84; /* #515154 */
|
||||
--fg-muted: 110 110 115; /* #6e6e73 */
|
||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
|
||||
--status-success: 23 107 46; /* #176b2e */
|
||||
--status-danger: 179 0 14; /* #b3000e */
|
||||
--status-warning: 122 79 0; /* #7a4f00 */
|
||||
--syn-comment: 93 108 121; /* #5d6c79 */
|
||||
--syn-string: 196 26 22; /* #c41a16 */
|
||||
--syn-number: 28 0 207; /* #1c00cf */
|
||||
--syn-keyword: 155 35 147; /* #9b2393 */
|
||||
--syn-builtin: 11 79 121; /* #0b4f79 */
|
||||
--syn-function: 50 109 116; /* #326d74 */
|
||||
--syn-operator: 75 75 80; /* #4b4b50 */
|
||||
--syn-punctuation: 75 75 80; /* #4b4b50 */
|
||||
--syn-property: 57 0 160; /* #3900a0 */
|
||||
--syn-tag: 155 35 147; /* #9b2393 */
|
||||
--syn-add: 31 122 54; /* #1f7a36 */
|
||||
--syn-del: 197 0 15; /* #c5000f */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="intellij-light"] {
|
||||
--surface-0: 236 238 242; /* #eceef2 */
|
||||
--surface-1: 241 242 245; /* #f1f2f5 */
|
||||
--surface-2: 247 248 250; /* #f7f8fa */
|
||||
--surface-3: 253 253 254; /* #fdfdfe */
|
||||
--surface-4: 255 255 255; /* #ffffff */
|
||||
--surface-5: 255 255 255; /* #ffffff */
|
||||
--border: 211 213 219; /* #d3d5db */
|
||||
--border-light: 228 230 235; /* #e4e6eb */
|
||||
--accent: 47 106 227; /* #2f6ae3 */
|
||||
--accent-hover: 39 88 192; /* #2758c0 */
|
||||
--fg-primary: 30 31 34; /* #1e1f22 */
|
||||
--fg-secondary: 73 75 87; /* #494b57 */
|
||||
--fg-muted: 108 112 126; /* #6c707e */
|
||||
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
|
||||
--status-success: 23 106 45; /* #176a2d */
|
||||
--status-danger: 173 28 38; /* #ad1c26 */
|
||||
--status-warning: 124 82 0; /* #7c5200 */
|
||||
--syn-comment: 112 112 112; /* #707070 */
|
||||
--syn-string: 6 125 23; /* #067d17 */
|
||||
--syn-number: 23 80 235; /* #1750eb */
|
||||
--syn-keyword: 0 51 179; /* #0033b3 */
|
||||
--syn-builtin: 0 0 128; /* #000080 */
|
||||
--syn-function: 0 98 122; /* #00627a */
|
||||
--syn-operator: 75 75 75; /* #4b4b4b */
|
||||
--syn-punctuation: 75 75 75; /* #4b4b4b */
|
||||
--syn-property: 135 16 148; /* #871094 */
|
||||
--syn-tag: 0 51 179; /* #0033b3 */
|
||||
--syn-add: 6 125 23; /* #067d17 */
|
||||
--syn-del: 192 31 42; /* #c01f2a */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="canvas"] {
|
||||
--surface-0: 227 220 205; /* #e3dccd */
|
||||
--surface-1: 232 226 212; /* #e8e2d4 */
|
||||
--surface-2: 237 232 220; /* #ede8dc */
|
||||
--surface-3: 243 239 230; /* #f3efe6 */
|
||||
--surface-4: 247 244 237; /* #f7f4ed */
|
||||
--surface-5: 250 248 243; /* #faf8f3 */
|
||||
--border: 203 191 166; /* #cbbfa6 */
|
||||
--border-light: 220 211 192; /* #dcd3c0 */
|
||||
--accent: 122 82 48; /* #7a5230 */
|
||||
--accent-hover: 102 63 34; /* #663f22 */
|
||||
--fg-primary: 43 38 32; /* #2b2620 */
|
||||
--fg-secondary: 94 85 74; /* #5e554a */
|
||||
--fg-muted: 107 97 85; /* #6b6155 */
|
||||
--status-success: 54 93 36; /* #365d24 */
|
||||
--status-danger: 147 41 31; /* #93291f */
|
||||
--status-warning: 109 76 0; /* #6d4c00 */
|
||||
--syn-comment: 125 114 98; /* #7d7262 */
|
||||
--syn-string: 77 107 31; /* #4d6b1f */
|
||||
--syn-number: 154 91 0; /* #9a5b00 */
|
||||
--syn-keyword: 138 59 18; /* #8a3b12 */
|
||||
--syn-builtin: 47 94 110; /* #2f5e6e */
|
||||
--syn-function: 122 90 0; /* #7a5a00 */
|
||||
--syn-operator: 138 48 80; /* #8a3050 */
|
||||
--syn-punctuation: 107 97 85; /* #6b6155 */
|
||||
--syn-property: 59 102 96; /* #3b6660 */
|
||||
--syn-tag: 142 47 47; /* #8e2f2f */
|
||||
--syn-add: 77 107 31; /* #4d6b1f */
|
||||
--syn-del: 159 47 35; /* #9f2f23 */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -102,12 +334,17 @@
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(0.8);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
/* The browser draws this glyph near-black. Inverting it is a DARK-mode fix;
|
||||
applied globally it painted a white icon onto a white field. */
|
||||
.dark ::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(0.8);
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
+16
-2
@@ -957,7 +957,14 @@ export const api = {
|
||||
node_version: string;
|
||||
platform: string;
|
||||
ws_connections: number;
|
||||
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
|
||||
memory: {
|
||||
rss: number;
|
||||
heapTotal: number;
|
||||
heapUsed: number;
|
||||
external: number;
|
||||
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
|
||||
heapLimit?: number;
|
||||
};
|
||||
cpu_load: number[];
|
||||
arch: string;
|
||||
total_mem: number;
|
||||
@@ -2290,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),
|
||||
|
||||
+22
-19
@@ -1033,46 +1033,49 @@ export function highlight(source: string, lang: string): Token[] {
|
||||
/**
|
||||
* Map a token type to a Tailwind colour class for rendering.
|
||||
* @param type A {@link TokenType} produced by {@link highlight}.
|
||||
* @returns Tailwind utility classes (text colour, and a background tint for
|
||||
* diff add/remove lines); falls back to a neutral gray for "plain"/unknown.
|
||||
* @returns Tailwind utility classes built on the `syntax.*` THEME TOKENS
|
||||
* (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
|
||||
* pastel one this file has always used, and light mode takes the same hues
|
||||
* four steps darker, because a pastel keyword on a light code block is
|
||||
* unreadable. Diff rows take a /20 status tint, pre-mixed per theme.
|
||||
*/
|
||||
export function tokenClass(type: TokenType): string {
|
||||
switch (type) {
|
||||
case "comment":
|
||||
return "text-gray-500 italic"; // muted + italic to recede visually
|
||||
return "text-syntax-comment italic"; // muted + italic to recede visually
|
||||
case "string":
|
||||
return "text-emerald-300"; // green, shared with diff-add text
|
||||
return "text-syntax-string"; // green, shared with diff-add text
|
||||
case "number":
|
||||
return "text-orange-300"; // orange, shared with boolean literals
|
||||
return "text-syntax-number"; // orange, shared with boolean literals
|
||||
case "keyword":
|
||||
return "text-violet-300"; // violet, shared with diff-meta headers
|
||||
return "text-syntax-keyword"; // violet, shared with diff-meta headers
|
||||
case "builtin":
|
||||
return "text-sky-300";
|
||||
return "text-syntax-builtin";
|
||||
case "function":
|
||||
return "text-yellow-200"; // yellow, shared with HTML attr names
|
||||
return "text-syntax-function"; // yellow, shared with HTML attr names
|
||||
case "operator":
|
||||
return "text-pink-300";
|
||||
return "text-syntax-operator";
|
||||
case "punctuation":
|
||||
return "text-gray-400"; // slightly brighter than comments, dimmer than plain
|
||||
return "text-syntax-punctuation"; // brighter than comments, dimmer than plain
|
||||
case "property":
|
||||
return "text-cyan-300";
|
||||
return "text-syntax-property";
|
||||
case "tag":
|
||||
return "text-rose-300";
|
||||
return "text-syntax-tag";
|
||||
case "attr":
|
||||
return "text-yellow-200"; // matches "function" colour by design
|
||||
return "text-syntax-function"; // matches "function" colour by design
|
||||
case "variable":
|
||||
return "text-amber-300";
|
||||
return "text-syntax-function"; // amber family, same as functions/attrs
|
||||
case "boolean":
|
||||
return "text-orange-300"; // literals grouped with numbers by colour
|
||||
return "text-syntax-number"; // literals grouped with numbers by colour
|
||||
case "diff-add":
|
||||
return "text-emerald-300 bg-emerald-500/10"; // green text + faint green row tint
|
||||
return "text-syntax-add bg-status-success/20"; // green text + theme-aware green row tint
|
||||
case "diff-del":
|
||||
return "text-red-300 bg-red-500/10"; // red text + faint red row tint
|
||||
return "text-syntax-del bg-status-danger/20"; // red text + theme-aware red row tint
|
||||
case "diff-meta":
|
||||
return "text-violet-300";
|
||||
return "text-syntax-keyword";
|
||||
case "plain":
|
||||
default:
|
||||
return "text-gray-200"; // default body text colour
|
||||
return "text-syntax-plain"; // default body text colour
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,14 @@ interface SystemInfo {
|
||||
node_version: string;
|
||||
platform: string;
|
||||
ws_connections: number;
|
||||
memory: { rss: number; heapTotal: number; heapUsed: number; external: number };
|
||||
memory: {
|
||||
rss: number;
|
||||
heapTotal: number;
|
||||
heapUsed: number;
|
||||
external: number;
|
||||
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
|
||||
heapLimit?: number;
|
||||
};
|
||||
cpu_load: number[];
|
||||
arch: string;
|
||||
total_mem: number;
|
||||
@@ -1562,10 +1569,10 @@ function SystemHealthTab() {
|
||||
|
||||
const memUsedPct =
|
||||
info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0;
|
||||
const heapUsedPct =
|
||||
info.server.memory.heapTotal > 0
|
||||
? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100
|
||||
: 0;
|
||||
// Against the heap LIMIT: heapTotal is only what V8 has grown to so far, so
|
||||
// used/total reads 70-90% on a healthy process and dragged the health score.
|
||||
const heapMax = info.server.memory.heapLimit || info.server.memory.heapTotal;
|
||||
const heapUsedPct = heapMax > 0 ? (info.server.memory.heapUsed / heapMax) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalEntries,
|
||||
@@ -1701,7 +1708,7 @@ function SystemHealthTab() {
|
||||
</Tip>
|
||||
<Tip
|
||||
block
|
||||
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
|
||||
raw={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapLimit || info.server.memory.heapTotal)} limit\nAllocated: ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppearanceSettings />
|
||||
|
||||
{/* ─── MODEL PRICING ─── */}
|
||||
<section id="pricing" className="scroll-mt-24">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
|
||||
@@ -5578,7 +5578,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<div
|
||||
class="mt-1 text-[10px] text-fg-muted"
|
||||
>
|
||||
Cmd+Enter / Ctrl+Enter to send
|
||||
Enter to send · Shift+Enter for newline
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
@@ -6945,6 +6945,52 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<div
|
||||
class="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-palette w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="13.5"
|
||||
cy="6.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<circle
|
||||
cx="17.5"
|
||||
cy="10.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<circle
|
||||
cx="8.5"
|
||||
cy="7.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<circle
|
||||
cx="6.5"
|
||||
cy="12.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<path
|
||||
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
Appearance
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
|
||||
type="button"
|
||||
@@ -7342,6 +7388,702 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="appearance"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-palette w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="13.5"
|
||||
cy="6.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<circle
|
||||
cx="17.5"
|
||||
cy="10.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<circle
|
||||
cx="8.5"
|
||||
cy="7.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<circle
|
||||
cx="6.5"
|
||||
cy="12.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
<path
|
||||
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
Appearance
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
Colour mode and theme. Saved in this browser only.
|
||||
</p>
|
||||
<div
|
||||
class="card p-5 space-y-5"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-medium text-fg-secondary mb-2"
|
||||
>
|
||||
Mode
|
||||
</p>
|
||||
<div
|
||||
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
|
||||
>
|
||||
<button
|
||||
aria-pressed="true"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-moon w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
aria-pressed="false"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-sun w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
/>
|
||||
<path
|
||||
d="M12 2v2"
|
||||
/>
|
||||
<path
|
||||
d="M12 20v2"
|
||||
/>
|
||||
<path
|
||||
d="m4.93 4.93 1.41 1.41"
|
||||
/>
|
||||
<path
|
||||
d="m17.66 17.66 1.41 1.41"
|
||||
/>
|
||||
<path
|
||||
d="M2 12h2"
|
||||
/>
|
||||
<path
|
||||
d="M20 12h2"
|
||||
/>
|
||||
<path
|
||||
d="m6.34 17.66-1.41 1.41"
|
||||
/>
|
||||
<path
|
||||
d="m19.07 4.93-1.41 1.41"
|
||||
/>
|
||||
</svg>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
aria-pressed="false"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-monitor w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="14"
|
||||
rx="2"
|
||||
width="20"
|
||||
x="2"
|
||||
y="3"
|
||||
/>
|
||||
<line
|
||||
x1="8"
|
||||
x2="16"
|
||||
y1="21"
|
||||
y2="21"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="21"
|
||||
/>
|
||||
</svg>
|
||||
System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-medium text-fg-secondary mb-2"
|
||||
>
|
||||
Dark themes
|
||||
<span
|
||||
class="ml-2 text-fg-muted font-normal"
|
||||
>
|
||||
· in use now
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"
|
||||
>
|
||||
<button
|
||||
aria-label="Dark themes: Default"
|
||||
aria-pressed="true"
|
||||
class="rounded-lg border p-1.5 text-left transition-colors border-accent ring-2 ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="dark rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
|
||||
data-mode="dark"
|
||||
>
|
||||
<div
|
||||
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-accent"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-[10px] leading-tight"
|
||||
>
|
||||
<span
|
||||
class="text-syntax-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-function"
|
||||
>
|
||||
x
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-operator"
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-string"
|
||||
>
|
||||
"ok"
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-success/40"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="block px-1 pt-1.5 text-xs text-fg-primary"
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Dark themes: Darcula"
|
||||
aria-pressed="false"
|
||||
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="dark rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
|
||||
data-mode="dark"
|
||||
data-theme="darcula"
|
||||
>
|
||||
<div
|
||||
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-accent"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-[10px] leading-tight"
|
||||
>
|
||||
<span
|
||||
class="text-syntax-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-function"
|
||||
>
|
||||
x
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-operator"
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-string"
|
||||
>
|
||||
"ok"
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-success/40"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="block px-1 pt-1.5 text-xs text-fg-primary"
|
||||
>
|
||||
Darcula
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Dark themes: Apple Dark"
|
||||
aria-pressed="false"
|
||||
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="dark rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
|
||||
data-mode="dark"
|
||||
data-theme="apple-dark"
|
||||
>
|
||||
<div
|
||||
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-accent"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-[10px] leading-tight"
|
||||
>
|
||||
<span
|
||||
class="text-syntax-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-function"
|
||||
>
|
||||
x
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-operator"
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-string"
|
||||
>
|
||||
"ok"
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-success/40"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="block px-1 pt-1.5 text-xs text-fg-primary"
|
||||
>
|
||||
Apple Dark
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-medium text-fg-secondary mb-2"
|
||||
>
|
||||
Light themes
|
||||
</p>
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"
|
||||
>
|
||||
<button
|
||||
aria-label="Light themes: Default"
|
||||
aria-pressed="true"
|
||||
class="rounded-lg border p-1.5 text-left transition-colors border-accent ring-2 ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
|
||||
data-mode="light"
|
||||
>
|
||||
<div
|
||||
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-accent"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-[10px] leading-tight"
|
||||
>
|
||||
<span
|
||||
class="text-syntax-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-function"
|
||||
>
|
||||
x
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-operator"
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-string"
|
||||
>
|
||||
"ok"
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-success/40"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="block px-1 pt-1.5 text-xs text-fg-primary"
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Light themes: Apple Light"
|
||||
aria-pressed="false"
|
||||
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
|
||||
data-mode="light"
|
||||
data-theme="apple-light"
|
||||
>
|
||||
<div
|
||||
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-accent"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-[10px] leading-tight"
|
||||
>
|
||||
<span
|
||||
class="text-syntax-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-function"
|
||||
>
|
||||
x
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-operator"
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-string"
|
||||
>
|
||||
"ok"
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-success/40"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="block px-1 pt-1.5 text-xs text-fg-primary"
|
||||
>
|
||||
Apple Light
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Light themes: IntelliJ Light"
|
||||
aria-pressed="false"
|
||||
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
|
||||
data-mode="light"
|
||||
data-theme="intellij-light"
|
||||
>
|
||||
<div
|
||||
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-accent"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-[10px] leading-tight"
|
||||
>
|
||||
<span
|
||||
class="text-syntax-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-function"
|
||||
>
|
||||
x
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-operator"
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-string"
|
||||
>
|
||||
"ok"
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-success/40"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="block px-1 pt-1.5 text-xs text-fg-primary"
|
||||
>
|
||||
IntelliJ Light
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Light themes: Canvas"
|
||||
aria-pressed="false"
|
||||
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
|
||||
data-mode="light"
|
||||
data-theme="canvas"
|
||||
>
|
||||
<div
|
||||
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-accent"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="font-mono text-[10px] leading-tight"
|
||||
>
|
||||
<span
|
||||
class="text-syntax-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-function"
|
||||
>
|
||||
x
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-operator"
|
||||
>
|
||||
=
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-syntax-string"
|
||||
>
|
||||
"ok"
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-success/40"
|
||||
/>
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="block px-1 pt-1.5 text-xs text-fg-primary"
|
||||
>
|
||||
Canvas
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="pricing"
|
||||
@@ -9617,7 +10359,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.8.1 (rebuild client to match)
|
||||
UI build v1.8.4 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,43 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/**
|
||||
* A status/accent FILL is pre-mixed against one fixed base, never translucent.
|
||||
*
|
||||
* `bg-status-success/10` used to emit `rgb(var(--status-success) / 0.1)`, which
|
||||
* composites with whatever is behind it — so the one "success" chip rendered as
|
||||
* five different colours over the five surface steps, and washed out badly on
|
||||
* the light theme's near-white cards. Mixing against `--tint-base` (the card
|
||||
* surface of the active theme) gives an OPAQUE colour: the same chip reads the
|
||||
* same on the page, in a card, in a modal and inside an input row.
|
||||
*
|
||||
* Applied to `backgroundColor`/`borderColor` only. `text-status-warning/80` and
|
||||
* `ring-accent/40` stay genuinely translucent — text and rings sit on top of a
|
||||
* fill rather than replacing it — and the surface family keeps its alpha too,
|
||||
* or every modal scrim would turn solid.
|
||||
*/
|
||||
const tint =
|
||||
(variable) =>
|
||||
({ opacityValue }) => {
|
||||
const alpha = Number(opacityValue);
|
||||
// No modifier at all: Tailwind passes `var(--tw-bg-opacity, 1)` here.
|
||||
if (!Number.isFinite(alpha) || alpha >= 1) return `rgb(var(${variable}))`;
|
||||
return `color-mix(in srgb, rgb(var(${variable})) ${alpha * 100}%, rgb(var(--tint-base)))`;
|
||||
};
|
||||
|
||||
const tinted = {
|
||||
accent: {
|
||||
DEFAULT: tint("--accent"),
|
||||
hover: tint("--accent-hover"),
|
||||
muted: "color-mix(in srgb, rgb(var(--accent)) 15%, rgb(var(--tint-base)))",
|
||||
},
|
||||
status: {
|
||||
success: tint("--status-success"),
|
||||
danger: tint("--status-danger"),
|
||||
warning: tint("--status-warning"),
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
@@ -37,12 +74,29 @@ export default {
|
||||
secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
|
||||
muted: "rgb(var(--fg-muted) / <alpha-value>)",
|
||||
},
|
||||
syntax: {
|
||||
comment: "rgb(var(--syn-comment) / <alpha-value>)",
|
||||
string: "rgb(var(--syn-string) / <alpha-value>)",
|
||||
number: "rgb(var(--syn-number) / <alpha-value>)",
|
||||
keyword: "rgb(var(--syn-keyword) / <alpha-value>)",
|
||||
builtin: "rgb(var(--syn-builtin) / <alpha-value>)",
|
||||
function: "rgb(var(--syn-function) / <alpha-value>)",
|
||||
operator: "rgb(var(--syn-operator) / <alpha-value>)",
|
||||
punctuation: "rgb(var(--syn-punctuation) / <alpha-value>)",
|
||||
property: "rgb(var(--syn-property) / <alpha-value>)",
|
||||
tag: "rgb(var(--syn-tag) / <alpha-value>)",
|
||||
plain: "rgb(var(--syn-plain) / <alpha-value>)",
|
||||
add: "rgb(var(--syn-add) / <alpha-value>)",
|
||||
del: "rgb(var(--syn-del) / <alpha-value>)",
|
||||
},
|
||||
status: {
|
||||
success: "rgb(var(--status-success) / <alpha-value>)",
|
||||
danger: "rgb(var(--status-danger) / <alpha-value>)",
|
||||
warning: "rgb(var(--status-warning) / <alpha-value>)",
|
||||
},
|
||||
},
|
||||
backgroundColor: tinted,
|
||||
borderColor: tinted,
|
||||
fontFamily: {
|
||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
||||
|
||||
+4
-1
@@ -145,7 +145,10 @@ path. Existing cwd values still pass through unchanged. `pipeline` is
|
||||
optional and, like `title`, applies **only when a lane is actually created**
|
||||
— an existing lane is returned untouched. Omitted, the lane gets `default`.
|
||||
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
|
||||
field. An id no template defines returns `400 EBADPIPELINE`.
|
||||
field. `exact: true` makes only a lane on this very directory count: without
|
||||
it a `cwd` nested inside another lane returns that parent lane (`created:
|
||||
false`); with it the nested directory gets a lane of its own. The Add-lane
|
||||
modal sends it, and says so when the directory already has a lane. An id no template defines returns `400 EBADPIPELINE`.
|
||||
|
||||
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
|
||||
runs will spawn. Immutable once the lane is created; an attempt to update it on
|
||||
|
||||
+5
-1
@@ -444,8 +444,12 @@ An external session is deduped against a dashboard run with the same `session_id
|
||||
|
||||
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
|
||||
|
||||
Live, dashboard-spawned rows (not external — there is no tmux session to kill for those) also carry a **Kill** button, `DELETE /api/run/:id` behind a two-click confirm. This is how you free a lane stuck on a previous live session when you actually want it gone, rather than attached to.
|
||||
|
||||
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
|
||||
|
||||
**A lane whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The console does not surface that raw error: `LaneConsolePane` catches it, re-reads the lane, and attaches to the run it is already holding instead — with a notice explaining that the previous session is still live, so nothing new started. Killing that session (Active runs list → **Kill**, two clicks, the same confirm convention as discarding a file in the Changes panel) is what actually frees the lane for a fresh Start or a different Resume; the kill goes straight through `DELETE /api/run/:id`, and the lane's own `run_id`/`status` self-heal to idle the next time anything reads the lane (`healRunId` in `routes/lanes.js`), so no separate lane-side call is needed.
|
||||
|
||||
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
|
||||
|
||||
### Finding or adopting a lane: `POST /api/lanes/ensure`
|
||||
@@ -468,7 +472,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
|
||||
```
|
||||
|
||||
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
|
||||
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it.
|
||||
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
|
||||
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
|
||||
- A relative or missing `cwd` returns `400 EBADCWD`.
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Appearance: mode + named themes, and readable diff rows
|
||||
|
||||
## Requirement (from the user, 2026-09-23)
|
||||
- `/settings` gets an **Appearance** section: mode **Dark / Light / System**, plus a named theme per mode.
|
||||
- Themes: dark = **Default** (current Radix slate), **Darcula**, **Apple Dark**; light = **Default** (current ivory), **Apple Light**, **IntelliJ Light**, **Canvas** (linen).
|
||||
- A theme also recolours code syntax (`--syn-*`).
|
||||
- The sidebar's dark/light control stays; each mode remembers its own theme (dark=Darcula, light=Canvas → toggling swaps between those two).
|
||||
- The diff's +/- rows in Code changes (`LaneDiffView`) get visibly more contrast, in every theme.
|
||||
|
||||
## Approach
|
||||
Themes are pure CSS-variable overrides — every colour in the app already reads `--surface-*`, `--fg-*`, `--accent*`, `--status-*`, `--tint-base`, `--syn-*`. No component learns theme names.
|
||||
|
||||
1. **`client/src/index.css`**
|
||||
- Light default block becomes `:root, [data-mode="light"]` (so a light preview can be scoped inside a dark page).
|
||||
- After the `.dark` block, one block per theme: dark ones `.dark[data-theme="<id>"]`, light ones `[data-theme="<id>"]` (placed after the defaults, same specificity as `[data-mode=light]`, later wins). Each overrides only what differs; `--tint-base` always = its `--surface-3`.
|
||||
- New `--diff-add` / `--diff-del` NOT needed: rows use existing `status-*` tokens.
|
||||
2. **`client/src/hooks/useTheme.ts`** → module-level store read via `useSyncExternalStore`, so Settings and Sidebar never disagree.
|
||||
- Storage: `theme` key now holds `dark|light|system` (old values stay valid → no migration); `agenthub.theme.dark` / `agenthub.theme.light` hold theme ids; unknown id ⇒ `default`.
|
||||
- `system` resolves via `matchMedia("(prefers-color-scheme: dark)")` and follows its `change` event.
|
||||
- Applies `.dark` class + `data-theme` attribute on `<html>` (attribute removed for `default`).
|
||||
- Keeps today's API (`theme` = resolved `dark|light`, `setTheme`, `toggleTheme` — toggle sets the explicit opposite mode) and adds `mode`, `setMode`, `themeFor`, `setThemeFor(mode, id)`, plus exported `THEMES` registry `{ dark: [...ids], light: [...ids] }`.
|
||||
3. **`client/src/pages/Settings.tsx`** — new first section `appearance` in `SETTINGS_SECTIONS`: a 3-button mode segment, then two grids (Dark themes / Light themes) of cards. Each card's swatch is a `<div data-mode|class=dark data-theme=id>` painting `bg-surface-0`, a `bg-surface-3` card, an accent dot and two syntax colours — the CSS vars re-scope inside it, so previews need zero duplicated hex. Selected card = accent ring + `aria-pressed`.
|
||||
4. **`client/src/components/Sidebar.tsx`** — expanded panel's theme grid gains **System** (3 buttons, Monitor icon); collapsed toggle unchanged.
|
||||
5. **`client/src/components/lanes/LaneDiffView.tsx`** `rowTint` → `bg-status-*/20` + a 3px inset left bar in the status colour; the `+`/`-` marker coloured `text-status-*` and bold. `highlight.ts`'s `add`/`del` classes use the same `/20` status tokens instead of fixed `emerald/red-500/10`.
|
||||
6. **i18n** `en`/`vi`: `nav.themeNames.system`, `settings.appearance.*` (title, mode, darkThemes, lightThemes, theme names).
|
||||
|
||||
## Revisions after the plan challenge
|
||||
1. Store keeps NO cached module state: `getSnapshot` reads localStorage + `matchMedia` and returns a primitive string; `subscribe` listens to a custom `agenthub-theme` event, `storage` (cross-tab) and the media-query `change`. No DOM work at import. DOM applied synchronously in the setter and in a `useLayoutEffect` (no one-frame flash).
|
||||
2. `window.matchMedia?.(...)` guarded (jsdom lacks it); the system test installs its own listener-capable mock.
|
||||
3. `<html>` carries `data-mode="dark|light"` too; light themes scoped `[data-mode="light"][data-theme=x]`, dark `.dark[data-theme=x]` — no source-order dependency.
|
||||
4. `--tint-base: var(--surface-3)` and `--syn-plain: var(--fg-primary)` declared once in each default block; theme blocks never set them.
|
||||
5. Diff emphasis `LaneDiffView.tsx:48` `bg-white/15` → `bg-fg-primary/15` (invisible on light otherwise).
|
||||
6. Swatch root sets `text-fg-primary` + `bg-surface-0`.
|
||||
7. Palettes pinned below; contrast checked with a small node script (white-on-accent, fg-muted on surface-3, status on its /20 chip) — dark themes held to the SAME bar the current dark default meets (its #0090ff is 3.3:1 under white text), light themes to ≥4.5:1.
|
||||
8. Appearance section lives in its own component `client/src/components/settings/AppearanceSettings.tsx`; Settings.tsx gains only the section entry + render.
|
||||
9. Sidebar: check the expanded panel width with 3 theme buttons; collapsed toggle moves System → the explicit opposite mode (documented).
|
||||
|
||||
## Palettes (hex; surface-0..5, border, border-light, accent, accent-hover, fg 1/2/muted, success/danger/warning; syntax)
|
||||
- **darcula**: #2b2b2b #303234 #36383a #3c3f41 #45494a #4e5254 · #4b4f51 #5e6366 · #3592c4 #4aa3d6 · #dcdcdc #a9b7c6 #8c9296 · #6aab73 #ff6b68 #e0b25c · keyword #cc7832 string #6a8759 number #6897bb comment #808080 function #ffc66d property #9876aa tag #e8bf6a builtin #8888c6 operator/punct #a9b7c6 add #6aab73 del #ff6b68
|
||||
- **apple-dark**: #161618 #1c1c1e #242426 #2c2c2e #363638 #3a3a3c · #38383a #48484a · #0a84ff #409cff · #f5f5f7 #aeaeb2 #8e8e93 · #30d158 #ff6961 #ffd60a · keyword #fc5fa3 string #fc6a5d number #d0bf69 comment #7f8c98 function #67b7a4 builtin #5dd8ff property #a167e6 tag #fc5fa3 operator/punct #a1a1a6 add #30d158 del #ff6961
|
||||
- **apple-light**: #e9e9ee #efeff4 #f5f5f8 #fbfbfd #fdfdfe #ffffff · #d1d1d6 #e5e5ea · #0066cc #0055b3 · #1d1d1f #515154 #6e6e73 · #1f7a36 #c5000f #8a5a00 · keyword #9b2393 string #c41a16 number #1c00cf comment #5d6c79 function #326d74 builtin #0b4f79 property #3900a0 tag #9b2393 operator/punct #4b4b50 add #1f7a36 del #c5000f
|
||||
- **intellij-light**: #eceef2 #f1f2f5 #f7f8fa #fdfdfe #ffffff #ffffff · #d3d5db #e4e6eb · #3574f0 #2e63cf · #1e1f22 #494b57 #6c707e · #1d7a35 #c01f2a #8f5f00 · keyword #0033b3 string #067d17 number #1750eb comment #707070 function #00627a builtin #000080 property #871094 tag #0033b3 operator/punct #4b4b4b add #067d17 del #c01f2a
|
||||
- **canvas**: #e3dccd #e8e2d4 #ede8dc #f3efe6 #f7f4ed #faf8f3 · #cbbfa6 #dcd3c0 · #7a5230 #663f22 · #2b2620 #5e554a #6b6155 · #3f6a2a #9f2f23 #7d5700 · keyword #8a3b12 string #4d6b1f number #9a5b00 comment #7d7262 function #7a5a00 builtin #2f5e6e property #3b6660 tag #8e2f2f operator #8a3050 punct #6b6155 add #4d6b1f del #9f2f23
|
||||
(Values that fail the contrast script are darkened/lightened until they pass; the final CSS is the source of truth.)
|
||||
|
||||
## Out of scope
|
||||
- Server-side persistence (theme stays a per-browser preference, like lane order).
|
||||
- ~~A no-flash inline boot script~~ — added after acceptance at the user's request (`client/index.html`).
|
||||
- User-defined/custom themes.
|
||||
- Surfaces that already bypass the CSS variables (they clash with today's light default too): the xterm terminal (no `theme`, TerminalView.tsx), Dashboard/workflow chart hardcoded hex (Dashboard.tsx, OrchestrationDAG.tsx, ErrorPropagationMap.tsx), ~415 raw Tailwind palette classes (toolStyle.ts, types.ts, …), `index.html`'s `text-gray-200` body.
|
||||
|
||||
## Tests
|
||||
- `hooks/__tests__/useTheme.test.ts`: existing 4 stay green; add — `system` follows a mocked `matchMedia` and its change event; `setThemeFor` sets `data-theme` only for the active mode and persists; unknown stored id ⇒ default (no attribute); two hook instances stay in sync.
|
||||
- Settings: a test that clicking a theme card sets `data-theme` / clicking System sets mode.
|
||||
- Snapshot diff for Settings/Sidebar reviewed, then regenerated deliberately.
|
||||
- `npm run test:client`, `tsc` via build, `check-headers.sh`. Manual: build + open `/settings`, screenshot each theme.
|
||||
|
||||
## Acceptance
|
||||
- [ ] Mode Dark/Light/System works and persists across reload; System follows the OS live.
|
||||
- [ ] Each of the 7 themes selectable; per-mode memory; sidebar toggle swaps between the two remembered themes.
|
||||
- [ ] Code syntax recolours per theme.
|
||||
- [ ] Diff +/- rows clearly distinguishable in every theme.
|
||||
- [ ] Docs (client README / CLAUDE.md marker) updated.
|
||||
+7
-3
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.8.1
|
||||
version: 1.8.4
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -13,7 +13,7 @@ externalDocs:
|
||||
description: Project documentation
|
||||
url: https://git.smartgift.io.vn/Smartgift-AI/AgentHub#readme
|
||||
servers:
|
||||
- url: http://localhost:4820
|
||||
- url: http://localhost:4830
|
||||
description: Local dashboard server (default)
|
||||
- url: http://127.0.0.1:4820
|
||||
description: Local loopback endpoint used by hook-handler
|
||||
@@ -3906,6 +3906,9 @@ components:
|
||||
type: string
|
||||
description: Title for the lane if one has to be created; ignored when one already exists.
|
||||
example: App package
|
||||
exact:
|
||||
type: boolean
|
||||
description: Only a lane on this very directory counts; a parent directory's lane is not returned, so a nested directory gets its own lane.
|
||||
LaneDestructiveActionRequest:
|
||||
type: object
|
||||
required:
|
||||
@@ -6196,6 +6199,7 @@ paths:
|
||||
heapTotal: 71303168
|
||||
heapUsed: 58392104
|
||||
external: 3211884
|
||||
heapLimit: 4345298944
|
||||
arrayBuffers: 1048576
|
||||
cpu_load:
|
||||
- 2.13
|
||||
@@ -10632,7 +10636,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: Find or adopt the lane owning a working directory
|
||||
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
operationId: ensureLane
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.4",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,9 @@ if [ -z "$MARKETPLACE" ]; then
|
||||
echo "agenthub is not installed as a plugin (checkout install?) - nothing to update this way."
|
||||
else
|
||||
claude plugin marketplace update "$MARKETPLACE"
|
||||
claude plugin update "agenthub@$MARKETPLACE" -y
|
||||
# An older Claude Code has no `-y` (and no prompt for it to answer).
|
||||
claude plugin update "agenthub@$MARKETPLACE" -y 2>/dev/null ||
|
||||
claude plugin update "agenthub@$MARKETPLACE"
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
@@ -83,6 +83,21 @@ function resolveClaude() {
|
||||
}
|
||||
|
||||
function run(cmd, args, timeout) {
|
||||
try {
|
||||
return runOnce(cmd, args, timeout);
|
||||
} catch (err) {
|
||||
// An older Claude Code rejects `-y` ("unknown option") and never prompts,
|
||||
// so the same command without the flag is the right retry.
|
||||
if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err;
|
||||
return runOnce(
|
||||
cmd,
|
||||
args.filter((a) => a !== "-y"),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runOnce(cmd, args, timeout) {
|
||||
log(`$ ${cmd} ${args.join(" ")}`);
|
||||
const out = execFileSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -102,6 +102,21 @@ function findNewRoot(exclude) {
|
||||
}
|
||||
|
||||
function run(cmd, args, timeout) {
|
||||
try {
|
||||
return runOnce(cmd, args, timeout);
|
||||
} catch (err) {
|
||||
// An older Claude Code rejects `-y` ("unknown option") and never prompts,
|
||||
// so the same command without the flag is the right retry.
|
||||
if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err;
|
||||
return runOnce(
|
||||
cmd,
|
||||
args.filter((a) => a !== "-y"),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runOnce(cmd, args, timeout) {
|
||||
log(`$ ${cmd} ${args.join(" ")}`);
|
||||
const out = execFileSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -1896,6 +1896,14 @@ describe("Transcript cache integration", () => {
|
||||
assert.equal(res.body.server.version, pkg.version);
|
||||
});
|
||||
|
||||
it("reports the V8 heap LIMIT, not just the currently allocated heapTotal", async () => {
|
||||
const res = await fetch("/api/settings/info");
|
||||
const { heapUsed, heapTotal, heapLimit } = res.body.server.memory;
|
||||
assert.ok(Number.isFinite(heapLimit), "heapLimit should be a number");
|
||||
// heapTotal is what V8 has grown to so far; the limit is the ceiling.
|
||||
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
|
||||
});
|
||||
|
||||
it("should evict cache entry on SessionEnd", async () => {
|
||||
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -116,6 +116,22 @@ describe("cc-plugin-cli argv construction", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retries without -y on a Claude Code too old to know the flag", async () => {
|
||||
// Older CLIs answer `error: unknown option '-y'` and exit 1; they also
|
||||
// never prompt, so the same command without the flag is the right retry.
|
||||
const restore = stubClaude(
|
||||
'for a in "$@"; do [ "$a" = "-y" ] && { echo "error: unknown option \'-y\'" >&2; exit 1; }; done\n' +
|
||||
'for a in "$@"; do echo "$a"; done'
|
||||
);
|
||||
try {
|
||||
const r = await cli.pluginUpdate({ spec: "demo@m" });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(argvOf(r), ["plugin", "update", "demo@m"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes -y on install so a non-TTY run never waits for a prompt", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -130,7 +130,23 @@ function serialize(fn) {
|
||||
* @param {{timeoutMs?:number}} [opts]
|
||||
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
|
||||
*/
|
||||
function runClaude(args, opts = {}) {
|
||||
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
|
||||
// It also predates the confirmation prompt the flag answers, so the same
|
||||
// command without it is the correct retry, not a weaker one.
|
||||
const NO_YES_FLAG = /unknown option '-y'/;
|
||||
|
||||
async function runClaude(args, opts = {}) {
|
||||
const result = await runClaudeOnce(args, opts);
|
||||
if (!result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
|
||||
return runClaudeOnce(
|
||||
args.filter((a) => a !== "-y"),
|
||||
opts
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runClaudeOnce(args, opts = {}) {
|
||||
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||
return new Promise((resolve, reject) => {
|
||||
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
|
||||
|
||||
@@ -128,6 +128,18 @@ function missingSkillPlugins() {
|
||||
* @returns {{ok: boolean, output: string}}
|
||||
*/
|
||||
function runClaudePlugin(args, timeoutMs) {
|
||||
const result = runClaudePluginOnce(args, timeoutMs);
|
||||
// An older Claude Code rejects `-y` and never prompts; retry without it.
|
||||
if (!result.ok && args.includes("-y") && /unknown option '-y'/.test(result.output)) {
|
||||
return runClaudePluginOnce(
|
||||
args.filter((a) => a !== "-y"),
|
||||
timeoutMs
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runClaudePluginOnce(args, timeoutMs) {
|
||||
const env = { ...process.env };
|
||||
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
||||
const result = spawnSync("claude", args, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -139,6 +139,7 @@ const SETTINGS_INFO_EXAMPLE = {
|
||||
heapTotal: 71303168,
|
||||
heapUsed: 58392104,
|
||||
external: 3211884,
|
||||
heapLimit: 4345298944,
|
||||
arrayBuffers: 1048576,
|
||||
},
|
||||
cpu_load: [2.13, 2.45, 2.31],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ const { Router } = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const v8 = require("v8");
|
||||
const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db");
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
const { transcriptCache } = require("./hooks");
|
||||
@@ -121,7 +122,10 @@ router.get("/info", (req, res) => {
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
ws_connections: getConnectionCount(),
|
||||
memory: process.memoryUsage(),
|
||||
// heapTotal is only what V8 has grown the heap to SO FAR, so used/total
|
||||
// sits at 70-90% on a perfectly healthy process. heapLimit is the ceiling
|
||||
// the process actually dies at — the denominator a "heap used %" needs.
|
||||
memory: { ...process.memoryUsage(), heapLimit: v8.getHeapStatistics().heap_size_limit },
|
||||
cpu_load: os.loadavg(),
|
||||
arch: os.arch(),
|
||||
total_mem: os.totalmem(),
|
||||
|
||||
Reference in New Issue
Block a user