Compare commits

...

4 Commits

Author SHA1 Message Date
nntrivi2001 837da6e5de fix: v2.3.1 — correct version, say why a plugin update failed, gitignored files in Files
- v2.3.0's tree reported 2.2.1, so every install saw an update forever;
  package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the
  snapshot now read 2.3.1
- A failed plugin-update step records the tail of the command's stdout and
  stderr plus its exit code or signal (failureText); plugin-bootstrap.js
  prints its refusal on stdout, which the verdict used to drop. Update all
  uses the same text
- updates.test.js never applies an update: it skips when the checkout is
  behind a release (apply ran npm run setup and pruned dev dependencies)
- Files tree lists gitignored files and folders, named in orange
  (addIgnored in git-diff.js); change-tree rows indent in whole steps
2026-10-06 16:22:53 +07:00
nntrivi2001 7ca1134c3d test(DiffEditor): wait for the split view's revert column, drawn on a measure pass 2026-10-06 15:43:47 +07:00
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00
nntrivi2001 f0500dc592 feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
2026-10-06 12:36:06 +07:00
91 changed files with 3781 additions and 857 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) 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|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — 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
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) 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|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — 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.
+112
View File
@@ -8,6 +8,10 @@
"name": "agenthub-client",
"version": "1.0.0",
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0",
@@ -410,6 +414,66 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.13",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1052,6 +1116,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2580,6 +2674,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -5702,6 +5802,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -6402,6 +6508,12 @@
"node": ">=0.10.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+4
View File
@@ -11,6 +11,10 @@
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0",
+7
View File
@@ -70,6 +70,7 @@ import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus";
import { copyOnCtrlShiftC } from "./lib/keys";
import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
@@ -128,6 +129,12 @@ export default function App() {
return () => window.removeEventListener("vite:preloadError", onPreloadError);
}, []);
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
useEffect(() => {
window.addEventListener("keydown", copyOnCtrlShiftC, true);
return () => window.removeEventListener("keydown", copyOnCtrlShiftC, true);
}, []);
return (
<>
<SplashScreen />
+1 -1
View File
@@ -7,7 +7,7 @@
* codex-pets.net for more, and open the Sagi settings. The choice and the size
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
* with Settings → Pet.
* with Settings → Appearance.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+1 -1
View File
@@ -315,7 +315,7 @@ export function Sagi() {
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/pet");
navigate("/settings/appearance#pet");
}}
/>
</SagiFlyout>
@@ -159,7 +159,7 @@ describe("PetMenu", () => {
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
// Says where to bring it back.
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
expect(toasts.some((x) => /Settings → Appearance/.test(x.message))).toBe(true);
off();
});
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @file usePets — the pet list and its actions, shared by the mascot's
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
* right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`):
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
* a featured pet (downloaded on its first pick), uploading an image or a
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
+10 -8
View File
@@ -396,13 +396,15 @@ export function Sidebar({
onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]);
// Same density and selection as a workspace row, so the whole sidebar
// reads as one list: no border, the active item tinted with an accent icon.
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`;
return (
@@ -451,7 +453,7 @@ export function Sidebar({
Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 space-y-1">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
const label = t(key);
return (
@@ -475,7 +477,7 @@ export function Sidebar({
onClick={onToggle}
title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<FolderTree className="w-4 h-4" />
</button>
@@ -528,8 +530,8 @@ export function Sidebar({
data-testid="sidebar-toggle"
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
{collapsed ? (
+264 -6
View File
@@ -11,22 +11,30 @@
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
* its fullest window, live account only — and, on hover or focus, a panel
* above grouping every account per tool (saved Claude profiles too): name,
* sign-in expiry, and a row per window with its bar, percent and reset time.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react";
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import type { CostResult } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
import type { CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
@@ -64,6 +72,255 @@ function CostFigure({ testId, label, r }: { testId: string; label: string; r: Co
);
}
/** Re-run `load` every `ms` and whenever the tab becomes visible again. */
function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) {
useEffect(() => {
const tick = () => document.visibilityState === "visible" && load();
tick();
const id = setInterval(tick, ms);
document.addEventListener("visibilitychange", tick);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0));
const quotaTone = (p: number) =>
p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : "";
const barTone = (p: number) =>
p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted";
function untilText(iso: string | null): string | null {
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
if (!(ms > 0)) return null;
const m = Math.floor(ms / 60_000);
const d = Math.floor(m / 1440);
const h = Math.floor((m % 1440) / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
}
/** "account · sign-in state · as of", whichever parts the entry has. */
function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) => string) {
const until = untilText(q.expiresAt ?? null);
return [
q.label && q.account,
q.expiresAt &&
q.error !== "ELIVEEXPIRED" &&
(until
? t("statusBar.quotaSignInExpires", { time: until })
: t("statusBar.quotaSignInExpired")),
q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }),
]
.filter(Boolean)
.join(" · ");
}
const QUOTA_ERROR_KEY: Record<string, string> = {
ELIVEEXPIRED: "statusBar.quotaExpired",
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
};
/** One account: its name, sign-in line, then a row per window with its reset. */
function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
const { t } = useTranslation("nav");
const meta = quotaMeta(q, t);
return (
<div data-testid="status-bar-quota-account" className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{q.label || q.account}</span>
{many && q.active !== false && (
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
{t("statusBar.quotaActive")}
</span>
)}
</div>
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
{q.error && !q.windows.length ? (
<div className="text-status-warning">
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
</div>
) : (
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
{q.windows.map((w) => {
const p = Math.round(w.usedPercent ?? 0);
const resets = untilText(w.resetsAt);
return (
<Fragment key={w.label}>
<span className="text-fg-muted">
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
</span>
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full ${barTone(p)}`}
style={{ width: `${Math.min(p, 100)}%` }}
/>
</span>
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
<span className="flex items-center justify-end gap-1 text-fg-muted">
{resets && (
<>
<RotateCcw className="h-3 w-3" aria-hidden />
<span aria-hidden>{resets}</span>
<span className="sr-only">
{t("statusBar.quotaResets", { time: resets })}
</span>
</>
)}
</span>
</Fragment>
);
})}
</div>
)}
</div>
);
}
function QuotaPanel({
tools,
onEnter,
onLeave,
onRefresh,
refreshing,
}: {
tools: ToolQuota[];
/** The footer's Refresh: re-reads the quota, as a click on the chips does. */
onRefresh: () => void;
/** A forced read is in flight: the Refresh icon spins. */
refreshing: boolean;
/** The pointer moved onto the panel / off it: it stays open while read. */
onEnter: () => void;
onLeave: () => void;
}) {
const { t } = useTranslation("nav");
const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][])
.map((id) => [id, tools.filter((q) => q.id === id)] as const)
.filter(([, list]) => list.length);
return createPortal(
<div
role="dialog"
aria-label={t("statusBar.quotaTitle")}
data-testid="status-bar-quota-panel"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
onFocus={onEnter}
onBlur={onLeave}
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
>
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
{groups.map(([id, list]) => (
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
<div className="mb-1 flex items-center gap-1.5">
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
</div>
<div className="space-y-2 pl-[1.375rem]">
{list.map((q, i) => (
<QuotaAccount
key={`${q.label ?? q.account ?? ""}-${i}`}
q={q}
many={list.length > 1}
/>
))}
</div>
</div>
))}
<button
type="button"
data-testid="status-bar-quota-refresh"
// Not `disabled`: a focused button that turns disabled loses focus, and
// that blur would close the panel under the pointer.
onClick={() => !refreshing && onRefresh()}
aria-busy={refreshing}
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
refreshing ? "cursor-default" : "hover:text-fg-primary"
}`}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
</button>
</div>,
document.body
);
}
/** The right-end quota chips; nothing at all until a tool reports one. */
function QuotaChips() {
const { t } = useTranslation("nav");
const [tools, setTools] = useState<ToolQuota[]>([]);
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>();
const show = () => {
window.clearTimeout(closeTimer.current);
setOpen(true);
};
const hide = () => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
};
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback((force = false) => {
if (force) setRefreshing(true);
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined)
.finally(() => force && setRefreshing(false));
}, []);
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
if (!tools.length) return null;
return (
<>
<button
type="button"
data-testid="status-bar-quota"
data-tip-off
onClick={() => load(true)}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
>
{tools
.filter((q) => q.active !== false)
.map((q) => {
const p = Math.round(fullest(q));
return (
<span key={q.id} className="flex items-center gap-1">
<AiToolIcon
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
className="h-4 w-4 !p-0"
/>
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
{q.error ? (
<span className="text-status-warning">!</span>
) : (
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
)}
</span>
);
})}
</button>
{open && (
<QuotaPanel
tools={tools}
onEnter={show}
onLeave={hide}
onRefresh={() => load(true)}
refreshing={refreshing}
/>
)}
</>
);
}
export function StatusBar({
wsConnected,
left,
@@ -213,6 +470,7 @@ export function StatusBar({
r={sessionCost}
/>
)}
<QuotaChips />
</footer>
);
}
+9 -4
View File
@@ -29,8 +29,9 @@
* ## Dismissal persistence
* Dismissals are keyed in `localStorage` by the release tag when there is one,
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
* a dismissed one stays dismissed. Settings can reset dismissal via the
* `dashboard:reset-update-dismissal` window event.
* a dismissed one stays dismissed. The connection modal's "Check for updates"
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -184,7 +185,11 @@ export function UpdateNotifier() {
}, [syncFromPayload]);
useEffect(() => {
const handler = () => setDismissedSha(null);
// An explicit "Check for updates" undoes both kinds of close.
const handler = () => {
setDismissedSha(null);
setClosedKey(null);
};
window.addEventListener("dashboard:reset-update-dismissal", handler);
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
}, []);
@@ -313,7 +318,7 @@ export function UpdateNotifier() {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="update-notifier-title"
@@ -4,12 +4,13 @@
* state with the version (click opens the connection details), the running and
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
* with its tokens and a per-model hover list, and on the Workspace page the
* open workspace's and the shown session's totals.
* open workspace's and the shown session's totals; the right-end quota chips
* (fullest window per tool) and their hover panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
@@ -68,6 +69,42 @@ vi.mock("../../lib/api", () => ({
],
daily_costs: [],
})),
quota: vi.fn().mockResolvedValue({
tools: [
{
id: "claude",
active: true,
label: "Work",
account: "me@example.com",
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
windows: [
{
label: "5h",
usedPercent: 95,
resetsAt: new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(),
},
{ label: "wk", usedPercent: 32, resetsAt: null },
],
},
{
id: "claude",
active: false,
label: "Personal",
account: "home@example.com",
expiresAt: "2020-01-01T00:00:00.000Z",
windows: [],
error: "EPROFILEEXPIRED",
},
{
id: "codex",
account: "dev@example.com",
expiresAt: "2020-01-01T00:00:00.000Z",
windows: [],
error: "EUPSTREAM",
},
],
checkedAt: "",
}),
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
total_cost: id === "s-live" ? 4.5 : 7,
breakdown: [],
@@ -144,4 +181,52 @@ describe("StatusBar", () => {
});
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
});
it("shows each tool's fullest quota window and the details on hover", async () => {
const user = userEvent.setup();
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chips = await screen.findByTestId("status-bar-quota");
expect(chips).toHaveTextContent("Claude95% used");
expect(chips.textContent?.match(/Claude/g)).toHaveLength(1);
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
await user.hover(chips);
const panel = screen.getByTestId("status-bar-quota-panel");
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workin use");
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
expect(work).toHaveTextContent("5h95%41m");
expect(work).toHaveTextContent("Week32%");
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
expect(personal).not.toHaveTextContent("in use");
expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired");
expect(panel).toHaveTextContent("Could not read the quota");
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
await user.unhover(chips);
await user.hover(panel);
await new Promise((r) => setTimeout(r, 300));
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
// Only the footer button refreshes: it spins and says so until the read lands.
const same = await api.pricing.quota(false);
let answer!: (v: unknown) => void;
vi.mocked(api.pricing.quota)
.mockClear()
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never);
const refresh = screen.getByTestId("status-bar-quota-refresh");
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
expect(refresh).toHaveTextContent("Refreshing…");
expect(refresh).toHaveAttribute("aria-busy", "true");
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
await act(async () => answer(same));
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
await user.unhover(panel);
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
vi.mocked(api.pricing.quota).mockClear();
await user.click(chips);
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
});
});
@@ -238,6 +238,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
});
it("Check for updates re-opens a modal closed with Later", async () => {
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
});
it("ignores Escape while an update is applying", async () => {
applyMock.mockReturnValue(new Promise(() => {}));
render(<UpdateNotifier />);
@@ -82,6 +82,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
forgetHistory: vi.fn(async () => ({ removed: "" })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
@@ -417,6 +418,30 @@ describe("WorkspaceTree", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("removes a closed terminal from its hover X and its right-click menu", async () => {
const shell = (id: string) => ({
id,
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
});
vi.mocked(api.run.history).mockImplementation(
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
await user.click(screen.getByText("Remove from list"));
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
});
it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
+330
View File
@@ -0,0 +1,330 @@
/**
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
* file's original text (HEAD, or a branch's merge base), editable while the
* diff re-colours as you type. `unified` is one editor with the removed lines
* inline above their replacement (`unifiedMergeView`); `split` is the
* original read-only on the left beside the editable file (`MergeView`).
* `collapse` folds unchanged stretches away — the panel's Changes view —
* and every changed block carries a Revert that puts the original back in the
* editor (nothing is written until Save). Green marks a line added whole, red
* a line removed whole, blue a line edited in place (it keeps some of its
* text), its edit in a stronger blue — the same scheme as the read-only diff in
* LaneDiffView. Syntax colours come from the dashboard's own per-line
* tokenizer (`lib/highlight.ts`), like every other code view here.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { EditorState, RangeSetBuilder } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
drawSelection,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
import { highlight, tokenClass } from "../../lib/highlight";
/**
* True when a letter or digit of a line (`text`, starting at document offset
* `from`) lies outside every changed range: an edit inside the line, not a
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
* a `}`) does not make two lines the same line.
*/
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
for (let i = 0; i < text.length; i += 1) {
if (!/\w/.test(text[i]!)) continue;
const at = from + i;
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
}
return false;
}
const modLine = Decoration.line({ class: "cm-ah-mod" });
const marks = new Map<string, Decoration>();
const mark = (cls: string) => {
let d = marks.get(cls);
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
return d;
};
/** Syntax marks and the edited-in-place class for the visible lines — on
* either side of a split view (`a` is the original). */
function decorate(view: EditorView, lang: string): DecorationSet {
const out = new RangeSetBuilder<Decoration>();
const info = getChunks(view.state);
const a = info?.side === "a";
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
const chunks = info?.chunks ?? [];
const doc = view.state.doc;
let last = 0;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = doc.lineAt(pos);
pos = line.to + 1;
if (line.number <= last) continue;
last = line.number;
const c = chunks.find((k) => {
const [from, to, end] = span(k);
return to! > from! && line.from >= from! && line.from <= end!;
});
const at0 = c ? span(c)[0]! : 0;
if (
c &&
keepsText(
line.text,
line.from,
c.changes.map((ch) =>
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
)
)
) {
out.add(line.from, line.from, modLine);
}
let at = line.from;
for (const tok of highlight(line.text, lang)) {
const cls = tokenClass(tok.type);
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
at += tok.text.length;
}
}
}
return out.finish();
}
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
* classed after the fact, by `keepsText`'s rule: text outside its
* `.cm-deletedText` spans is text the line kept. */
function classDeleted(root: HTMLElement) {
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
let kept = "";
(line.firstElementChild ?? line).childNodes.forEach((n) => {
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
kept += n.textContent ?? "";
}
});
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
});
}
const diffColours = (lang: string) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = decorate(view, lang);
this.classLater(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
this.classLater(u.view);
}
// After CodeMirror has drawn, and before the browser paints.
classLater(view: EditorView) {
view.requestMeasure({
key: this,
read: () => null,
write: () => classDeleted(view.contentDOM),
});
}
},
{ decorations: (p) => p.decorations }
);
const GREEN = "rgb(var(--status-success) / 0.18)";
const RED = "rgb(var(--status-danger) / 0.18)";
const BLUE = "rgb(var(--diff-mod) / 0.18)";
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
// base theme, whose selectors carry an extra light/dark scope class.
const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({
"&": {
height: "100%",
fontSize: "11px",
backgroundColor: "rgb(var(--surface-0))",
color: "rgb(var(--fg-primary))",
},
"&.cm-focused": { outline: "none" },
".cm-scroller": {
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
lineHeight: "1.45",
},
".cm-gutters": {
backgroundColor: "rgb(var(--surface-1))",
color: "rgb(var(--fg-muted))",
border: "none",
},
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "rgb(var(--accent) / 0.3)",
},
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${M} .cm-changedText`]: { background: "none" },
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
// The original, left of a split view: its changed lines are removals.
[`${A} .cm-changedLine`]: { backgroundColor: RED },
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
[`${A} .cm-changedText`]: { background: "none" },
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
"&.cm-editor.cm-editor .cm-collapsedLines": {
color: "rgb(var(--fg-muted))",
background: "rgb(var(--surface-2))",
},
".cm-chunkButtons": { zIndex: "1" },
});
/** The Revert button on a changed block; @codemirror/merge wires the click.
* Between split columns there is room for an arrow only (`→`: left onto right). */
function revertButton(label: string, arrow = false) {
const b = document.createElement("button");
b.type = "button";
b.textContent = arrow ? "→" : label;
b.title = label;
b.setAttribute("aria-label", label);
b.className =
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
return b;
}
export default function DiffEditor({
value,
original,
lang,
layout = "unified",
collapse = false,
label,
revertLabel,
onChange,
onSave,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
original: string;
lang: string;
/** One editor with removals inline, or the original beside the file. */
layout?: "unified" | "split";
/** Fold unchanged stretches away: only the changes and their context. */
collapse?: boolean;
label: string;
/** The text of each changed block's Revert button. */
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
}) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave });
cb.current = { onChange, onSave };
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme];
const editing = [
...common,
highlightActiveLineGutter(),
history(),
drawSelection(),
highlightActiveLine(),
keymap.of([
{
key: "Mod-s",
preventDefault: true,
run: () => {
cb.current.onSave();
return true;
},
},
indentWithTab,
...defaultKeymap,
...historyKeymap,
]),
EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
}),
];
let v: EditorView;
let destroy: () => void;
if (layout === "split") {
const mv = new MergeView({
parent: host.current!,
a: {
doc: original,
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
},
b: { doc: value, extensions: editing },
revertControls: "a-to-b",
renderRevertControl: () => revertButton(revertLabel, true),
collapseUnchanged: folded,
});
v = mv.b;
destroy = () => mv.destroy();
} else {
v = new EditorView({
parent: host.current!,
state: EditorState.create({
doc: value,
extensions: [
...editing,
unifiedMergeView({
original,
syntaxHighlightDeletions: false,
collapseUnchanged: folded,
// Accept would only hide a block; Revert is the one that edits.
mergeControls: (type, action) => {
const b = revertButton(revertLabel);
if (type === "accept") b.hidden = true;
else b.onmousedown = action;
return b;
},
}),
],
}),
});
destroy = () => v.destroy();
}
v.focus();
view.current = v;
return destroy;
// One mount per view: the parent keys this component by file and shape.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => {
const v = view.current;
if (v && v.state.doc.toString() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
}
}, [value]);
return (
<div
ref={host}
data-testid="lane-file-editor"
data-layout={layout}
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
/>
);
}
File diff suppressed because it is too large Load Diff
+24 -44
View File
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
export const ROW_PX = 16;
const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX };
const MIN_ROW: CSSProperties = { minHeight: ROW_PX };
/**
* The `[start, end)` item range to render for a scroll position. `scrollTop`
@@ -165,7 +166,7 @@ function Windowed({
}
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) {
export function langFor(filePath: string) {
const ext = filePath.split(".").pop() || "";
return canonicalLang(ext === filePath ? "plain" : ext);
}
@@ -177,7 +178,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-fg-primary/15">
<span className="rounded-sm bg-diff-mod/40">
<Code text={text.slice(from, to)} lang={lang} />
</span>
<Code text={text.slice(to)} lang={lang} />
@@ -209,9 +210,10 @@ function emphasis(
while (start < max && del[start] === add[start]) start += 1;
let end = 0;
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
// Nothing in common at either edge: the whole line is the change, and
// highlighting all of it adds noise on top of the row tint.
if (start === 0 && end === 0) return null;
// No letter or digit in common at either edge (blanks, a `;`, a `}` do not
// count): the whole line is the change — a removed line and an added one,
// not an edit — and highlighting all of it adds noise on top of the row tint.
if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null;
return {
del: [start, del.length - end],
add: [start, add.length - end],
@@ -294,7 +296,8 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
}
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
* `style` pins a windowed row's height; unwindowed rows carry none. */
* `style` pins a windowed row's height; an unwindowed row is at least one
* line tall, so a split view's empty filler row keeps its column in step. */
function Row({
tint,
style,
@@ -310,7 +313,7 @@ function Row({
return (
<div
className={`flex w-max min-w-full ${tint}`}
style={style}
style={style ?? MIN_ROW}
data-diff-item={style ? "" : undefined}
data-change-index={anchor}
>
@@ -320,15 +323,19 @@ function Row({
}
/** 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) {
* separate from context at a glance on every theme (the /10 fill alone did not).
* Green/red mark a line added or removed whole; blue (`mod`) a line edited in
* place — a -/+ pair that keeps part of its text, its edit emphasised. */
function rowTint(type: LaneDiffLine["type"] | null, mod = false) {
if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]";
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"]) {
function markerClass(type: LaneDiffLine["type"], mod = false) {
if (mod && type !== "ctx") return "font-bold text-diff-mod";
if (type === "add") return "font-bold text-status-success";
if (type === "del") return "font-bold text-status-danger";
return "text-fg-muted";
@@ -485,11 +492,11 @@ function UnifiedLine({
}) {
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
<Row tint={rowTint(line.type, !!emph)} style={style} anchor={anchor}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} commentable />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
<span className={`select-none ${markerClass(line.type, !!emph)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
@@ -558,7 +565,7 @@ function SplitLine({
const line = side === "left" ? row.left : row.right;
const anchor = useAnchor(start);
return (
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
<Gutter
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
commentable={side === "right"}
@@ -941,16 +948,12 @@ export function LaneFileView({
lines,
truncated,
fill = false,
onEditAt,
}: {
path: string;
lines: string[];
truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */
fill?: boolean;
/** Set when the file is editable: a click on the text (0-based line, and
* where the view was scrolled) asks to open the editor there. */
onEditAt?: (line: number, scrollTop: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
@@ -959,12 +962,9 @@ export function LaneFileView({
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
{/* `contents`: a line marker for the click handler, not a layout box. */}
<span data-ln={i} className="contents">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
);
@@ -972,27 +972,7 @@ export function LaneFileView({
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
tabIndex={onEditAt ? 0 : undefined}
onClick={
onEditAt &&
((e) => {
// A click that ends a drag-selection is copying, not asking to edit.
if (window.getSelection()?.toString()) return;
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
})
}
onKeyDown={
onEditAt &&
((e) => {
if (e.key !== "Enter") return;
e.preventDefault();
onEditAt(0, e.currentTarget.scrollTop);
})
}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
onEditAt ? " cursor-text" : ""
}`}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
>
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
+51 -27
View File
@@ -1,9 +1,11 @@
/**
* @file The top of the Changes panel: the lane's working-copy line (branch,
* head, subject, and the uncommitted counts) and the git action bar under it
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
* the open panel lists those files itself, so its header chip opens nothing.
* head, subject) and the git action bar under it (`LaneGitBar`); plus the
* uncommitted counts drawn vertical on the CLOSED Changes rail
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
* by the real total, listing the server's first 20, its "… more files" opening
* the Changes panel — the open panel lists those files itself, so its header
* shows no counts.
*
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
@@ -102,6 +104,7 @@ const GIT_STATUS_STYLE: Record<string, string> = {
function LaneGitFilesPanel({
files,
hidden,
onShowAll,
anchor,
side = "below",
onPointerEnter,
@@ -109,6 +112,8 @@ function LaneGitFilesPanel({
}: {
files: LaneGitFile[];
hidden: number;
/** Opens the Changes panel, which lists every file the cap left out. */
onShowAll?: () => void;
/** The counts button this panel hangs off. */
anchor: HTMLElement | null;
/** `left` beside a chip on the right-edge rail, where below would cover it. */
@@ -146,13 +151,14 @@ function LaneGitFilesPanel({
// partial one. `hidden` says how many are missing.
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
const total = files.length + hidden;
return createPortal(
<div
ref={panelRef}
data-testid="lane-git-files"
role="group"
aria-label={t("git.filesHeading", { count: files.length })}
aria-label={t("git.filesHeading", { count: total })}
// The pointer has to be able to travel INTO the panel to scroll it, so
// the panel answers the same enter/leave pair the trigger does.
onMouseEnter={onPointerEnter}
@@ -169,7 +175,7 @@ function LaneGitFilesPanel({
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
>
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
<span className="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
<span className="ml-auto shrink-0 font-mono tabular-nums">
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
@@ -206,30 +212,40 @@ function LaneGitFilesPanel({
);
})}
</ul>
{hidden > 0 && (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
)}
{hidden > 0 &&
(onShowAll ? (
<button
type="button"
data-testid="lane-git-files-more"
onClick={onShowAll}
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
>
{t("git.filesMore", { count: hidden })}
</button>
) : (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
))}
</div>,
document.body
);
}
/**
* The uncommitted counts as a chip. With `details` it opens the per-file panel
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
* else lists the files. In the open panel the list below already does, so the
* header shows the chip alone.
* The uncommitted counts as a chip that opens the per-file panel on hover,
* focus or click — the closed Changes rail, `vertical`, where nothing else
* lists the files.
*/
export function LaneGitCounts({
git,
vertical = false,
details = true,
onShowAll,
}: {
git: Extract<LaneGitFacts, { available: true }>;
vertical?: boolean;
details?: boolean;
/** See `LaneGitFilesPanel`. */
onShowAll?: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
@@ -253,13 +269,6 @@ export function LaneGitCounts({
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
}`;
if (!details) {
return (
<span data-testid="lane-git-counts" className={chip}>
{label}
</span>
);
}
return (
<>
<button
@@ -288,6 +297,13 @@ export function LaneGitCounts({
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
onShowAll={
onShowAll &&
(() => {
setFilesOpen(false);
onShowAll();
})
}
anchor={anchor}
side={vertical ? "left" : "below"}
onPointerEnter={openFiles}
@@ -300,9 +316,18 @@ export function LaneGitCounts({
/** The counts on the closed Changes rail: its own facts read, polled only
* while the rail is shown (the open panel owns the read otherwise). */
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
export function LaneGitRailCounts({
laneId,
session,
onShowAll,
}: {
laneId: number;
session?: string;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
const [git] = useLaneGitFacts(laneId, session);
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
}
export function LaneGitHeader({
@@ -339,7 +364,6 @@ export function LaneGitHeader({
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
<LaneGitCounts git={git} details={false} />
</>
);
return (
+1 -1
View File
@@ -160,7 +160,7 @@ export function WorkspaceRow({
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] text-fg-primary">{name}</span>
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
</button>
)}
{!renaming && (
@@ -14,7 +14,8 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -31,7 +32,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
@@ -42,7 +43,7 @@ import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
@@ -297,6 +298,15 @@ export function WorkspaceSessionList({
toast.error(err);
}
};
const closedCtx = useContextMenu<RunHistoryItem>();
const forgetShell = async (r: RunHistoryItem) => {
try {
await api.run.forgetHistory(r.id);
setClosedShells((all) => all.filter((x) => x.id !== r.id));
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -427,7 +437,7 @@ export function WorkspaceSessionList({
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
return (
<>
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
{failure}
{confirmArchive}
</>
@@ -435,7 +445,11 @@ export function WorkspaceSessionList({
}
return (
<>
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
<ul
data-testid={`workspace-sessions-${lane.id}`}
// A guide line under the workspace's chevron; the rows start past it.
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
>
{fresh.map((r) => (
<li key={r.id}>
<button
@@ -450,7 +464,7 @@ export function WorkspaceSessionList({
}
onContextMenu={(e) => ctx.open(e, r)}
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
r.id === selectedRunId
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -458,19 +472,19 @@ export function WorkspaceSessionList({
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt, true) : null}
</span>
{r.tool === "shell" ? (
<>
<span className="truncate">{t("tabs.terminal")}</span>
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
</>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
) : (
<>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
)}
</button>
</li>
@@ -480,7 +494,7 @@ export function WorkspaceSessionList({
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) {
return (
<li key={s.id} className="py-0.5 pl-7 pr-2">
<li key={s.id} className="py-0.5 pr-2">
<input
data-testid={`workspace-session-rename-${s.id}`}
aria-label={t("tree.renameSession")}
@@ -534,7 +548,7 @@ export function WorkspaceSessionList({
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -554,9 +568,12 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at, true)}
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
/>
</button>
<button
@@ -575,13 +592,14 @@ export function WorkspaceSessionList({
);
})}
{closed.map((r) => (
<li key={r.id}>
<li key={r.id} className="group relative">
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<span
aria-hidden
@@ -590,9 +608,22 @@ export function WorkspaceSessionList({
<span className="sr-only">{t("tree.state.idle")}</span>
<span className="truncate">{t("tabs.terminal")}</span>
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)}
{timeAgo(r.ended_at ?? r.started_at, true)}
</span>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
<SquareTerminal
aria-hidden
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
/>
</button>
<button
type="button"
data-testid={`workspace-terminal-forget-${r.id}`}
onClick={() => void forgetShell(r)}
title={t("tree.forgetTerminal")}
aria-label={t("tree.forgetTerminal")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
@@ -602,7 +633,7 @@ export function WorkspaceSessionList({
type="button"
data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => own && setLimit(own.total)}
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.seeAll", { count: more })}
</button>
@@ -619,6 +650,42 @@ export function WorkspaceSessionList({
onClose={ctx.close}
/>
)}
{closedCtx.menu && (
<ContextMenu
x={closedCtx.menu.x}
y={closedCtx.menu.y}
onClose={closedCtx.close}
testId={`terminal-menu-${closedCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(false);
}}
>
{t("tree.reopenTerminal")}
</MenuItem>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(true);
}}
>
{t("sessionMenu.openSplit")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const item = closedCtx.menu!.item;
closedCtx.close();
void forgetShell(item);
}}
>
{t("tree.forgetTerminal")}
</MenuItem>
</ContextMenu>
)}
{sessionCtx.menu && (
<SessionMenu
x={sessionCtx.menu.x}
@@ -0,0 +1,93 @@
/**
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
* count as edited in place (blue) rather than added or removed whole, and the
* editor's own diff classes on a real mount.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import DiffEditor, { keepsText } from "../DiffEditor";
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
Range.prototype.getClientRects ??= () => EMPTY;
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
describe("keepsText", () => {
it("is false for a line whose every non-blank character changed", () => {
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
expect(keepsText("", 0, [])).toBe(false);
// Only a `;` survived: not the same line.
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
});
it("is true when some text outside the changes survives", () => {
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
});
});
describe("DiffEditor", () => {
it("marks a whole new line green and a line edited in place blue", async () => {
const { container } = render(
<DiffEditor
value={"keep\nconst x = 2;\nbrand new"}
original={"keep\nconst x = 1;"}
lang="ts"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
await waitFor(() =>
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
);
});
it("side by side: the original read-only on the left, its edited line blue", async () => {
const { container } = render(
<DiffEditor
layout="split"
value={"keep\nconst x = 2;"}
original={"keep\nconst x = 1;"}
lang="ts"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
expect(left).toHaveAttribute("contenteditable", "false");
expect(right).toHaveAttribute("contenteditable", "true");
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
// The revert column is drawn in CodeMirror's next measure pass, not at mount.
await waitFor(() =>
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert")
);
});
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
const { container } = render(
<DiffEditor
collapse
value={`${same}\nchanged`}
original={`${same}\noriginal`}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
/>
);
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
});
});
@@ -13,13 +13,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
vi.mock("../DiffEditor", () => ({
default: (p: {
value: string;
original: string;
layout?: string;
collapse?: boolean;
label: string;
onChange: (v: string) => void;
onSave: () => void;
}) => (
<textarea
data-testid="lane-file-editor"
data-original={p.original}
data-layout={p.layout}
data-collapse={String(!!p.collapse)}
aria-label={p.label}
autoFocus
value={p.value}
onChange={(e) => p.onChange(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
p.onSave();
}
}}
/>
),
}));
vi.mock("../../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true),
@@ -32,6 +61,7 @@ vi.mock("../../../lib/api", () => ({
gitBranches: vi.fn(),
changes: vi.fn(),
gitAction: vi.fn(),
suggestCommitMessage: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
@@ -90,14 +120,20 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
};
}
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
}
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByText(path).closest("li")!).getByRole("button", {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
name: "Discard this file's changes",
});
}
beforeEach(() => {
window.localStorage.removeItem("agenthub.changes.neverStage.7");
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -113,6 +149,7 @@ beforeEach(() => {
author: "t",
date: "2026-09-22T01:00:00Z",
refs: ["HEAD -> feat/thing"],
parents: [],
},
],
});
@@ -140,8 +177,8 @@ beforeEach(() => {
});
});
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
/** The panel as the Workspace renders it: Files and Changes are its own mode
* buttons now, no activity-bar tab around it. */
function Tabbed({
laneId,
onDirtyChange,
@@ -149,18 +186,7 @@ function Tabbed({
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
}
describe("LaneChangesPanel", () => {
@@ -169,7 +195,7 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.queryByText(/new line/)).toBeNull();
});
@@ -194,7 +220,7 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
@@ -203,6 +229,7 @@ describe("LaneChangesPanel", () => {
context: 3,
})
);
await toDiff(user);
// The highlighter splits a line into one span per token, so assert on the
// rendered text of the whole view rather than on a single node.
const view = await screen.findByTestId("lane-diff-view");
@@ -258,6 +285,65 @@ describe("LaneChangesPanel", () => {
});
});
it("keeps a never-stage file out of Stage all, without telling git", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
await user.click(keep!); // the first row: server/lib/git-ops.js
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
"server/lib/git-ops.js",
]);
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
// And back: the row's button now offers the way out.
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
});
it("draws a never-stage file orange in the Files tree", async () => {
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
],
truncated: false,
});
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
expect(await row("notes.txt")).toHaveClass("text-kept");
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
expect(await row("a.txt")).toHaveClass("text-status-warning");
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
// `server/lib` holds nothing but `lib`: one compacted row.
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
@@ -341,16 +427,26 @@ describe("LaneChangesPanel", () => {
});
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
@@ -374,12 +470,12 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-view");
await screen.findByTestId("lane-file-editor");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
});
it("shows an image file as the picture, with no edit control", async () => {
@@ -408,7 +504,7 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -433,29 +529,29 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts"));
// Opening a file only shows it: no editor until the text itself is clicked.
const view = await screen.findByTestId("lane-file-view");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
await user.click(view);
// Opening a file is editing it, diffed against its HEAD text.
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
expect(editor).toHaveFocus();
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel asks before dropping an edit: "keep" keeps it…
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByTestId("lane-file-cancel"));
expect(confirmDiscard).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" drops it and goes back to reading.
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
// …and "discard" reverts it, still editing.
await user.click(screen.getByTestId("lane-file-cancel"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
});
it("at full width opens a changed file from the overview for reading, not editing", async () => {
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
@@ -468,32 +564,41 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await screen.findByRole("button", { name: "Changes", pressed: true });
// Changes is the editor folded to its changes, side by side at full width.
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("a");
expect(editor).toHaveAttribute("data-collapse", "true");
expect(editor).toHaveAttribute("data-layout", "split");
await user.type(editor, "b");
// Whole file unfolds the same edit — no prompt, nothing dropped.
vi.mocked(confirmDiscard).mockClear();
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
const whole = await screen.findByTestId("lane-file-editor");
expect(whole).toHaveAttribute("data-collapse", "false");
expect(whole).toHaveValue("ab");
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
expect(confirmDiscard).not.toHaveBeenCalled();
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
});
it("does not enter the editor when a click only ends a text selection", async () => {
it("opens diffs unified at the narrow width", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
rev: "work",
binary: false,
truncated: false,
lines: ["copy me"],
lines: ["a"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view");
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "copy",
} as unknown as Selection);
await user.click(view);
spy.mockRestore();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
});
it("needs two clicks to discard, and only the second one reaches the server", async () => {
@@ -517,7 +622,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByText("server/lib/git-ops.js");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
await right();
@@ -577,9 +682,9 @@ describe("LaneChangesPanel", () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js");
await screen.findByTitle("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
@@ -612,7 +717,7 @@ describe("LaneChangesPanel", () => {
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("opens a commit from history as a commit-range review", async () => {
@@ -621,6 +726,7 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
await user.click(await screen.findByText("feat: the commit"));
await waitFor(() =>
@@ -766,7 +872,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor);
@@ -779,36 +884,9 @@ describe("LaneChangesPanel", () => {
"const whole = 2;",
"b".repeat(40)
);
// Back to reading, showing what was saved.
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
// Still editing, on what was saved: nothing left to save.
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
@@ -852,7 +930,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save"));
@@ -892,7 +969,8 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
@@ -903,7 +981,8 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an
@@ -933,8 +1012,8 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/);
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() =>
@@ -995,11 +1074,10 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
for (const name of ["Overview", "Review", "History"]) {
for (const name of ["Changes", "Files", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
@@ -1351,9 +1429,9 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js");
const before = screen.getByTitle("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with ONE request, not one per folder", async () => {
@@ -1418,7 +1496,7 @@ describe("LaneChangesPanel", () => {
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
});
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
// Two lines removed, none added: the right column's two rows are fillers.
const file = fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
]);
render(<LaneDiffView file={file} layout="split" />);
const fillers = [
...screen
.getByTestId("lane-diff-split-right")
.querySelectorAll<HTMLElement>("div.flex.w-max"),
];
expect(fillers).toHaveLength(2);
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
});
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
}
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");
// A line added or removed whole: nothing in common with its pair.
const WHOLE = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
]);
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
const add = rowOf(container, "xyz");
const del = rowOf(container, "abc");
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)/);
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
});
it("tints a line edited in place blue on both sides", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
for (const text of ["x = 1", "x = 2"]) {
const row = rowOf(container, text);
expect(row.className).toContain("bg-diff-mod/20");
expect(row.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 { container } = render(<LaneDiffView file={WHOLE} 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 plus = marker(rowOf(container, "xyz"));
const minus = marker(rowOf(container, "abc"));
const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold");
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
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(
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
(e) => e.textContent
);
expect(emph).toEqual(["1", "2"]);
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
layout="unified"
/>
);
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
});
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
behind: null,
};
it("renders the live branch, head, subject and uncommitted counts", async () => {
it("renders the live branch, head and subject", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
});
it("drops the previous lane's facts the moment the lane changes", async () => {
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
render(<LaneGitRailCounts laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
});
it("shows the counts in the open panel's header without the hover list", async () => {
it("opens the Changes panel from the file list's “more files”", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
const onShowAll = vi.fn();
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
fireEvent.click(screen.getByTestId("lane-git-files-more"));
expect(onShowAll).toHaveBeenCalledOnce();
expect(screen.queryByTestId("lane-git-files")).toBeNull();
});
it("shows no counts in the open panel's header: the list below already does", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
fireEvent.focus(counts);
expect(screen.queryByTestId("lane-git-files")).toBeNull();
await screen.findByTestId("lane-git");
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
});
it("draws the rail's counts vertical", async () => {
@@ -1,8 +1,10 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/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
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is 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.
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
@@ -11,7 +13,8 @@
import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
@@ -61,7 +64,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
}
export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const reset = async () => {
@@ -78,67 +81,99 @@ export function AppearanceSettings() {
};
return (
<div className="card p-5 space-y-5">
<SettingsRow
label={t("appearance.mode")}
control={
<SettingsSegmented
ariaLabel={t("appearance.mode")}
value={mode}
onChange={setMode}
options={MODES.map(({ mode: m, Icon }) => ({
value: m,
label: (
<>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</>
),
}))}
/>
}
/>
<div className="space-y-6">
<SettingsSection
id="language"
title={t("language.title")}
description={t("language.description")}
>
<SettingsRow
label={t("language.label")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: t("language.names.en") },
{ value: "vi" as const, label: t("language.names.vi") },
]}
/>
}
/>
</SettingsSection>
{(["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>
);
})}
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow
label={t("appearance.mode")}
control={
<SettingsSegmented
ariaLabel={t("appearance.mode")}
value={mode}
onChange={setMode}
options={MODES.map(({ mode: m, Icon }) => ({
value: m,
label: (
<>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</>
),
}))}
/>
}
/>
{(["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>
))}
))}
<div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")}
</button>
</div>
<div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")}
</button>
</div>
</section>
<section id="pet" className="space-y-3 scroll-mt-24">
<div>
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.pet")}</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("appearance.petDescription")}</p>
</div>
<PetSettings />
</section>
</div>
);
}
@@ -1,5 +1,5 @@
/**
* @file PetSettings — Settings → Pet: the mascot's own page. Show it or not,
* @file PetSettings — the pet block of Settings → Appearance (`#pet`). Show it or not,
* its speech bubbles, its size (with a live preview), and which pet it wears
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
* install on the first pick, never bundled), and your own (deletable) — plus
@@ -1,14 +1,17 @@
/**
* @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>`.
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing 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 { describe, it, expect, beforeEach, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18n from "../../../i18n";
import { AppearanceSettings } from "../AppearanceSettings";
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
const root = document.documentElement;
describe("AppearanceSettings", () => {
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
root.removeAttribute("data-mode");
});
it("switches the interface language and holds the pet block", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
expect(screen.getByTestId("pet-settings").closest("section")).toHaveAttribute("id", "pet");
const group = screen.getByRole("radiogroup", { name: "Interface language" });
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
"aria-checked",
"true"
);
await user.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
expect(i18n.language).toBe("vi");
await act(async () => {
await i18n.changeLanguage("en");
});
});
it("renders every theme of both modes, defaults selected", () => {
render(<AppearanceSettings />);
const pressed = screen
+5 -1
View File
@@ -68,7 +68,11 @@
"justNow": "just now",
"mAgo": "{{count}}m ago",
"hAgo": "{{count}}h ago",
"dAgo": "{{count}}d ago"
"dAgo": "{{count}}d ago",
"justNowShort": "now",
"mAgoShort": "{{count}}m",
"hAgoShort": "{{count}}h",
"dAgoShort": "{{count}}d"
},
"pagination": {
"previous": "Previous",
+13 -3
View File
@@ -59,6 +59,10 @@
"changes.collapseFile": "Collapse {{path}}",
"changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged",
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
"changes.neverStage": "Never stage — Stage all skips this file",
"changes.allowStage": "Include in Stage all again",
"changes.keptHint": "Never staged by Stage all (this browser)",
"changes.conflicted": "conflict",
"changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone",
@@ -82,7 +86,7 @@
"changes.loading": "Reading the working copy…",
"changes.mode.browse": "Files",
"changes.mode.history": "History",
"changes.mode.overview": "Overview",
"changes.mode.overview": "Changes",
"changes.mode.review": "Review",
"changes.noCommits": "No commits yet.",
"changes.noHunks": "No textual change.",
@@ -98,6 +102,10 @@
"changes.size.compact": "Narrow",
"changes.size.half": "Medium",
"changes.size.full": "Full screen",
"changes.fileCount_one": "{{count}} file",
"changes.fileCount_other": "{{count}} files",
"changes.stageFolder": "Stage every file in this folder",
"changes.unstageFolder": "Unstage every file in this folder",
"changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage all ({{count}})",
"changes.dropEdits": "Discard your unsaved edits to this file?",
@@ -108,6 +116,7 @@
"changes.truncated": "Some changes were left out to keep this panel responsive.",
"changes.unavailable": "This workspace's directory is not a readable git repository.",
"changes.unstage": "Unstage this file",
"changes.changesView": "Changes",
"changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove",
@@ -234,8 +243,8 @@
"changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit",
"changes.save": "Save",
"changes.revertBlock": "Revert",
"changes.cancelEdit": "Cancel",
"changes.clickToEdit": "Click the text to edit",
"changes.preview": "Preview",
"changes.raw": "Raw",
"changes.editorLabel": "Edit {{path}}",
@@ -299,7 +308,6 @@
"menu.copyPath": "Copy path",
"menu.reset": "Reset worktree…",
"menu.remove": "Delete workspace…",
"panel.files": "Files",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
"replay.placeholder": "Message to continue this session…",
@@ -323,6 +331,8 @@
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
"tree.reopenTerminal": "Open a new terminal",
"tree.forgetTerminal": "Remove from list",
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
"tree.renameSession": "Rename session",
+14 -1
View File
@@ -67,7 +67,20 @@
"today": "Today {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)"
"tokens": "({{tokens}} Tokens)",
"quotaUsed": "{{pct}}% used",
"quotaTitle": "Usage",
"quotaResets": "Resets in {{time}}",
"quotaAsOf": "as of {{ago}}",
"quotaRefresh": "Refresh",
"quotaRefreshing": "Refreshing…",
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
"quotaFailed": "Could not read the quota",
"quotaActive": "in use",
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
"quotaSignInExpires": "Sign-in expires in {{time}}",
"quotaSignInExpired": "Sign-in expired",
"quotaWindow": { "wk": "Week", "mo": "Month" }
},
"version": "Version",
"openMenu": "Open menu",
+8 -11
View File
@@ -21,7 +21,7 @@
"import": "Import .codex-pet bundle…",
"browse": "Find more on codex-pets.net",
"settings": "Pet settings…",
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.",
"hiddenToast": "Pet hidden. Show it again in Settings → Appearance.",
"deleteTitle": "Delete {{name}}?",
"deleteMessage": "The pet's files are removed from this install. A featured pet can be downloaded again; an upload is gone for good.",
"deleteConfirm": "Delete pet",
@@ -587,6 +587,8 @@
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
"fieldModel": "Model",
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
"fieldBehavesAs": "Behaves as (Claude Code)",
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
"perTier": "Per-tier overrides",
"perTierHint": "Leave a field blank to use the model above.",
"tier": {
@@ -651,7 +653,10 @@
"reset": "Reset to defaults",
"resetTitle": "Reset appearance?",
"resetBody": "Mode goes back to Dark and both modes to their default theme.",
"resetDone": "Appearance reset."
"resetDone": "Appearance reset.",
"theme": "Theme",
"pet": "Pet",
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
},
"gitCreds": {
"title": "Git credentials",
@@ -751,17 +756,9 @@
"cost": "Cost"
},
"section": {
"general": {
"title": "General",
"description": "The dashboard's display language."
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only."
},
"pet": {
"title": "Pet",
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
"description": "Language, colour mode and theme, and the corner pet."
},
"notifications": {
"title": "Notifications",
+5 -1
View File
@@ -68,7 +68,11 @@
"justNow": "vừa xong",
"mAgo": "{{count}} phút trước",
"hAgo": "{{count}} giờ trước",
"dAgo": "{{count}} ngày trước"
"dAgo": "{{count}} ngày trước",
"justNowShort": "vừa xong",
"mAgoShort": "{{count}} phút",
"hAgoShort": "{{count}} giờ",
"dAgoShort": "{{count}} ngày"
},
"pagination": {
"previous": "Trước",
+13 -3
View File
@@ -59,6 +59,10 @@
"changes.collapseFile": "Thu gọn {{path}}",
"changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage",
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
"changes.neverStage": "Không bao giờ stage — Stage all bỏ qua file này",
"changes.allowStage": "Cho Stage all stage lại file này",
"changes.keptHint": "Stage all không bao giờ stage file này (trên trình duyệt này)",
"changes.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
@@ -82,7 +86,7 @@
"changes.loading": "Đang đọc working copy…",
"changes.mode.browse": "Tệp",
"changes.mode.history": "Lịch sử",
"changes.mode.overview": "Tổng quan",
"changes.mode.overview": "Thay đổi",
"changes.mode.review": "Xem toàn bộ",
"changes.noCommits": "Chưa có commit nào.",
"changes.noHunks": "Không có thay đổi nội dung.",
@@ -98,6 +102,10 @@
"changes.size.compact": "Hẹp",
"changes.size.half": "Vừa",
"changes.size.full": "Toàn màn hình",
"changes.fileCount_one": "{{count}} tệp",
"changes.fileCount_other": "{{count}} tệp",
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
"changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
@@ -108,6 +116,7 @@
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
"changes.unstage": "Bỏ file này khỏi index",
"changes.changesView": "Thay đổi",
"changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa",
@@ -234,8 +243,8 @@
"changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa",
"changes.save": "Lưu",
"changes.revertBlock": "Hoàn tác",
"changes.cancelEdit": "Huỷ",
"changes.clickToEdit": "Bấm vào nội dung để sửa",
"changes.preview": "Xem trước",
"changes.raw": "Mã nguồn",
"changes.editorLabel": "Sửa {{path}}",
@@ -299,7 +308,6 @@
"menu.copyPath": "Sao chép đường dẫn",
"menu.reset": "Đặt lại worktree…",
"menu.remove": "Xóa workspace…",
"panel.files": "Tệp",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
"replay.placeholder": "Nhắn để tiếp tục session này…",
@@ -323,6 +331,8 @@
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
"tree.reopenTerminal": "Mở terminal mới",
"tree.forgetTerminal": "Xóa khỏi danh sách",
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
"tree.renameSession": "Đổi tên session",
+14 -1
View File
@@ -67,7 +67,20 @@
"today": "Hôm nay {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)"
"tokens": "({{tokens}} Tokens)",
"quotaUsed": "đã dùng {{pct}}%",
"quotaTitle": "Mức dùng",
"quotaResets": "Đặt lại sau {{time}}",
"quotaAsOf": "lúc {{ago}}",
"quotaRefresh": "Làm mới",
"quotaRefreshing": "Đang làm mới…",
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
"quotaFailed": "Không đọc được quota",
"quotaActive": "đang dùng",
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
"quotaSignInExpired": "Đăng nhập đã hết hạn",
"quotaWindow": { "wk": "Tuần", "mo": "Tháng" }
},
"version": "Phiên bản",
"openMenu": "Mở menu",
+8 -11
View File
@@ -21,7 +21,7 @@
"import": "Nhập bundle .codex-pet…",
"browse": "Tìm thêm trên codex-pets.net",
"settings": "Cài đặt pet…",
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Pet.",
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Giao diện.",
"deleteTitle": "Xoá {{name}}?",
"deleteMessage": "Các tệp của pet sẽ bị xoá khỏi bản cài này. Pet nổi bật có thể tải lại; ảnh bạn tải lên sẽ mất hẳn.",
"deleteConfirm": "Xoá pet",
@@ -587,6 +587,8 @@
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
"fieldModel": "Model",
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
"fieldBehavesAs": "Hoạt động như (Claude Code)",
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
"perTier": "Ghi đè theo từng bậc",
"perTierHint": "Để trống một ô là dùng model ở trên.",
"tier": {
@@ -651,7 +653,10 @@
"reset": "Khôi phục mặc định",
"resetTitle": "Khôi phục giao diện?",
"resetBody": "Chế độ trở về Tối và cả hai chế độ trở về giao diện mặc định.",
"resetDone": "Đã khôi phục giao diện."
"resetDone": "Đã khôi phục giao diện.",
"theme": "Theme",
"pet": "Pet",
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
},
"gitCreds": {
"title": "Git credentials",
@@ -751,17 +756,9 @@
"cost": "Chi phí"
},
"section": {
"general": {
"title": "Chung",
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này."
},
"pet": {
"title": "Pet",
"description": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
},
"notifications": {
"title": "Thông báo",
+6
View File
@@ -105,6 +105,10 @@
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
/* A diff line edited in place (not wholly added or removed). */
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
--kept: 194 65 12; /* orange-700 #c2410c */
}
.dark {
@@ -154,6 +158,8 @@
--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 */
--diff-mod: 96 165 250; /* blue-400 #60a5fa */
--kept: 251 146 60; /* orange-400 #fb923c */
}
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
@@ -0,0 +1,51 @@
/**
* @file Tests for the Changes panel's folder tree: grouping by folder,
* compacting single-child chains, counts, ordering and the per-folder file list.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { changeTree, treeFiles } from "../changeTree";
const rows = [
"app2/Services/TwitterService.php",
"app2/Services/Redis/SlowAlertRedisService.php",
"app2/Enums/Redis/RedisSlowAlertKey.php",
"config/monitoring.php",
"config/app.php",
"README.md",
].map((path) => ({ path }));
describe("changeTree", () => {
const root = changeTree(rows);
it("puts root files at the top level and counts every file", () => {
expect(root.count).toBe(6);
expect(root.files.map((f) => f.name)).toEqual(["README.md"]);
expect(root.dirs.map((d) => d.name)).toEqual(["app2", "config"]);
});
it("compacts a folder chain with nothing else in it", () => {
const app2 = root.dirs[0]!;
expect(app2.count).toBe(3);
expect(app2.dirs.map((d) => [d.name, d.path])).toEqual([
["Enums/Redis", "app2/Enums/Redis"],
["Services", "app2/Services"],
]);
// Services holds a file, so it is not folded into Redis.
expect(app2.dirs[1]!.dirs[0]!.name).toBe("Redis");
});
it("sorts files by name and lists a folder's files", () => {
expect(root.dirs[1]!.files.map((f) => f.name)).toEqual(["app.php", "monitoring.php"]);
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
});
});
describe("changeTree — an untracked folder", () => {
it("shows git's `dir/` entry as a named row in its parent folder", () => {
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
const docs = root.dirs[0]!;
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
});
});
@@ -0,0 +1,48 @@
/**
* @file Tests for the History commit-graph layout: a straight line, a branch
* merged back, converging lanes and a parent beyond the loaded page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { commitGraph } from "../commitGraph";
const c = (sha: string, ...parents: string[]) => ({ sha, parents });
describe("commitGraph", () => {
it("draws a linear history in one column", () => {
const { rows, width } = commitGraph([c("c", "b"), c("b", "a"), c("a")]);
expect(width).toBe(1);
expect(rows.map((r) => r.col)).toEqual([0, 0, 0]);
// The tip has no line from above; the root none below.
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
expect(rows[2]?.segs).toEqual([{ x1: 0, y1: 0, x2: 0, y2: 0.5, color: 0 }]);
});
it("opens a lane for a merged branch and closes it at the fork point", () => {
// m merge of b2 into a2
// |\\
// a2 b2
// | b1
// |/
// a1
const { rows, width } = commitGraph([
c("m", "a2", "b2"),
c("a2", "a1"),
c("b2", "b1"),
c("b1", "a1"),
c("a1"),
]);
expect(width).toBe(2);
expect(rows.map((r) => r.col)).toEqual([0, 0, 1, 1, 0]);
expect(rows[0]?.segs).toContainEqual({ x1: 0, y1: 0.5, x2: 1, y2: 1, color: 1 });
// b1's lane converges into a1's column.
expect(rows[4]?.segs).toContainEqual({ x1: 1, y1: 0, x2: 0, y2: 0.5, color: 1 });
expect(rows[1]?.color).toBe(rows[0]?.color);
expect(rows[2]?.color).not.toBe(rows[0]?.color);
});
it("keeps a lane running for a parent past the page", () => {
const { rows } = commitGraph([c("b", "a")]);
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
});
});
+19
View File
@@ -390,6 +390,7 @@ import type {
CodexHooksStatus,
AntigravityHooksStatus,
CostResult,
ToolQuota,
DashboardEvent,
Lane,
LaneChangeRange,
@@ -1392,6 +1393,11 @@ export const api = {
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
/** GET /api/pricing/quota - plan quota spent per native tool; `force` for a Refresh. */
quota: (force = false) =>
request<{ tools: ToolQuota[]; checkedAt: string }>(
`/pricing/quota${force ? "?force=1" : ""}`
),
/**
* GET /api/pricing/cost/:sessionId - cost for one session, priced as of
* the session's start date.
@@ -2186,6 +2192,9 @@ export const api = {
if (opts.limit) qs.set("limit", String(opts.limit));
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
forgetHistory: (id: string) =>
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
@@ -2757,6 +2766,12 @@ export const api = {
current: string | null;
worktrees: LaneWorktree[];
}>(gitUrl(id, "/branches")),
/**
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
* written by the user's `claude` CLI (no tools, nothing persisted).
*/
suggestCommitMessage: (id: GitTarget) =>
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
@@ -3286,6 +3301,8 @@ export interface AiRouter {
hasKey: boolean;
model: string;
models: AiRouterModels;
/** Claude model id the router's own model names behave as; "" when unset. */
behavesAs?: string;
savedAt?: string;
}
@@ -3318,6 +3335,8 @@ export interface AiRouterInput {
model: string;
/** Any tier left out is filled from `model`. */
models?: Partial<AiRouterModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
}
/** One markdown-based config artifact (skill/agent/command/output-style),
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Folds the Changes panel's flat list of changed files into a folder
* tree, JetBrains' commit-view shape: folders first, then files, each sorted
* by name, and a chain of folders that hold nothing but one sub-folder drawn
* as ONE row (`src/lib`), so a deep path does not cost a row per level.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface ChangeTreeDir<T> {
/** The row's label — one segment, or several joined by `/` when compacted. */
name: string;
/** The folder's repo-relative path ("" for the root). */
path: string;
dirs: ChangeTreeDir<T>[];
files: { name: string; file: T }[];
/** Every file anywhere below. */
count: number;
}
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
for (const file of rows) {
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
const parts = file.path.replace(/\/$/, "").split("/");
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
let dir = root;
dir.count++;
for (const seg of parts.slice(0, -1)) {
const path = dir.path ? `${dir.path}/${seg}` : seg;
let next = dir.dirs.find((d) => d.path === path);
if (!next) dir.dirs.push((next = { name: seg, path, dirs: [], files: [], count: 0 }));
next.count++;
dir = next;
}
dir.files.push({ name: parts[parts.length - 1] ?? file.path, file });
}
const finish = (d: ChangeTreeDir<T>): ChangeTreeDir<T> => {
// Never the root: its children are the group's top level.
let only: ChangeTreeDir<T> | undefined;
while (d.path && d.files.length === 0 && d.dirs.length === 1 && (only = d.dirs[0])) {
d = { ...only, name: `${d.name}/${only.name}` };
}
d.dirs = d.dirs.map(finish).sort((a, b) => a.name.localeCompare(b.name));
d.files.sort((a, b) => a.name.localeCompare(b.name));
return d;
};
return finish(root);
}
/** Every file under a folder, for its stage/unstage-all button. */
export function treeFiles<T>(d: ChangeTreeDir<T>): T[] {
return [...d.files.map((f) => f.file), ...d.dirs.flatMap(treeFiles)];
}
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Lays out the Changes panel's History as a commit graph (the JetBrains /
* `git log --graph` shape): each commit gets a column, and each row lists the
* line segments drawn through it — a lane passing by, the lanes converging on
* the commit from above, and the lines leaving it for its parents below.
* Expects the commits in an order where no parent precedes a child
* (`git log --date-order`); a parent outside the list leaves its lane running
* off the bottom.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** One line in a row, in column units; y runs 0 (top) → 0.5 (node) → 1 (bottom). */
export interface GraphSeg {
x1: number;
y1: number;
x2: number;
y2: number;
color: number;
}
export interface GraphRow {
col: number;
color: number;
segs: GraphSeg[];
}
export function commitGraph(commits: { sha: string; parents: string[] }[]): {
rows: GraphRow[];
width: number;
} {
// lanes[j] = the sha the lane in column j is waiting for; colors[j] its hue.
const lanes: (string | null)[] = [];
const colors: number[] = [];
const hue = (j: number) => colors[j] ?? 0;
let nextColor = 0;
let width = 1;
const open = () => {
const free = lanes.indexOf(null);
const j = free < 0 ? lanes.length : free;
colors[j] = nextColor++;
return j;
};
const rows = commits.map((c) => {
const segs: GraphSeg[] = [];
let col = lanes.indexOf(c.sha);
// A tip nothing above points at: a lane of its own, no line from the top.
if (col < 0) {
col = open();
lanes[col] = null;
}
lanes.forEach((s, j) => {
if (s === null) return;
segs.push(
s === c.sha
? { x1: j, y1: 0, x2: col, y2: 0.5, color: hue(j) }
: { x1: j, y1: 0, x2: j, y2: 1, color: hue(j) }
);
});
const color = hue(col);
lanes.forEach((s, j) => {
if (s === c.sha) lanes[j] = null;
});
c.parents.forEach((p, i) => {
let j = i === 0 ? col : lanes.indexOf(p);
if (j < 0) j = open();
lanes[j] = p;
segs.push({ x1: col, y1: 0.5, x2: j, y2: 1, color: hue(j) });
});
while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();
width = Math.max(width, lanes.length, col + 1);
return { col, color, segs };
});
return { rows, width };
}
+8 -5
View File
@@ -337,23 +337,26 @@ export function formatMs(ms: number): string {
* Formats how long ago an ISO/SQLite timestamp was, as a translated relative
* string ("just now", "5m ago", "3h ago", "2d ago") using {@link i18n}.
* @param iso A past timestamp (see {@link parseDate}).
* @param short Drop the "ago" ("now", "5m") — the `common:time.*Short` keys.
* @returns A localized relative-time phrase; i18next handles pluralization via `count`.
* @remarks Thresholds cascade seconds -> minutes -> hours -> days (days is the largest
* bucket, so a 40-day-old event reads "40d ago"). Under a minute collapses to the
* "just now" key. Uses translated keys, not `Intl.RelativeTimeFormat`, so the exact
* wording is controlled by the `common:time.*` translation resources.
*/
export function timeAgo(iso: string): string {
export function timeAgo(iso: string, short = false): string {
const ms = Date.now() - parseDate(iso).getTime();
if (Number.isNaN(ms)) return "-";
// `short` drops the "ago" ("17m"), for a narrow column like the sidebar's.
const k = short ? "Short" : "";
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return i18n.t("common:time.justNow");
if (seconds < 60) return i18n.t(`common:time.justNow${k}`);
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return i18n.t("common:time.mAgo", { count: minutes });
if (minutes < 60) return i18n.t(`common:time.mAgo${k}`, { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return i18n.t("common:time.hAgo", { count: hours });
if (hours < 24) return i18n.t(`common:time.hAgo${k}`, { count: hours });
const days = Math.floor(hours / 24);
return i18n.t("common:time.dAgo", { count: days });
return i18n.t(`common:time.dAgo${k}`, { count: days });
}
// ===========================================================================
+13
View File
@@ -3,6 +3,8 @@
* @description Keyboard helpers shared by every single-line submit field.
* `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese
* Telex, CJK): without it a rename or commit message is committed half-typed.
* `copyOnCtrlShiftC` makes Ctrl+Shift+C a copy, as in a Linux terminal, instead
* of the browser's inspect-element shortcut (App.tsx installs it, capture phase).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,3 +15,14 @@ export function isSubmitEnter(e: ReactKeyboardEvent | KeyboardEvent): boolean {
const native = "nativeEvent" in e ? e.nativeEvent : e;
return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229;
}
/**
* Ctrl+Shift+C copies the selection — a field's, the page's or a terminal's
* (xterm answers the `copy` event) — instead of opening DevTools. F12 and
* Ctrl+Shift+I still open them. With nothing selected it does nothing.
*/
export function copyOnCtrlShiftC(e: KeyboardEvent): void {
if (e.code !== "KeyC" || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
e.preventDefault();
document.execCommand("copy");
}
+12 -5
View File
@@ -34,8 +34,9 @@ export const SETTINGS_INDEX: {
to?: string;
}[] = [
{
section: "general",
section: "appearance",
titleKey: "language.title",
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{
@@ -90,14 +91,20 @@ export const SETTINGS_INDEX: {
keywords: ["version", "server", "database", "uptime"],
},
{
section: "pet",
section: "appearance",
titleKey: "sagi.title",
descKey: "sagi.description",
keywords: ["sagi", "mascot", "pet"],
to: "#pet",
},
{ section: "appearance", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc", to: "#pet" },
{ section: "appearance", titleKey: "sagi.pet.size", to: "#pet" },
{
section: "appearance",
titleKey: "sagi.pet.choose",
keywords: ["codex-pets", "upload", "import"],
to: "#pet",
},
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
{ section: "pet", titleKey: "sagi.pet.size" },
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
+25
View File
@@ -1206,6 +1206,25 @@ export interface CostResult {
unpriced_models?: UnpricedModel[];
}
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
export interface ToolQuota {
id: "claude" | "codex";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100. */
windows: Array<{ label: string; usedPercent: number | null; resetsAt: string | null }>;
/** Why there is no figure, e.g. `ELIVEEXPIRED` (Claude's token needs a CLI refresh). */
error?: string;
/** False for a saved Claude auth profile that is not the live login. */
active?: boolean;
/** The saved profile's label, when the account has one. */
label?: string | null;
/** The signed-in account's email, when the tool's login names one. */
account?: string | null;
/** When the stored access token expires (ISO). */
expiresAt?: string | null;
/** When the figure was read, if not just now (Codex's journal, an expired profile). */
asOf?: string;
}
// ───── Import progress ─────
/** Payload of the `import.progress` WebSocket message, streamed while a
@@ -2716,6 +2735,8 @@ export interface LaneCommit {
date: string;
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
refs: string[];
/** Parent shas, first parent first — the history's commit graph. */
parents: string[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
@@ -2743,6 +2764,8 @@ export interface LaneTreeEntry {
status?: string;
/** A folder with a changed or untracked file anywhere under it. */
changed?: true;
/** Ignored by git (`.gitignore`) — drawn orange in the Files tree. */
ignored?: true;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
@@ -2795,6 +2818,8 @@ export type LaneChanges =
base: string | null;
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
baseRef: string | null;
/** `range: "branch"`: the merge base the diff is taken from (full sha). */
mergeBase?: string | null;
/** The commit being shown, for `range: "commit"`; its full sha. */
sha: string | null;
/** Lines of unchanged context each hunk carries. */
+14 -1
View File
@@ -39,8 +39,9 @@ import { useDialog } from "../hooks/useDialog";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
apiKey: string;
behavesAs: string;
models: Partial<AiRouterModels>;
};
@@ -50,6 +51,7 @@ const emptyForm = (): FormState => ({
apiKey: "",
model: "",
models: {},
behavesAs: "",
});
export function AiRouterConfig() {
@@ -121,6 +123,7 @@ export function AiRouterConfig() {
apiKey: "",
model: item.model,
models: { ...item.models },
behavesAs: item.behavesAs ?? "",
});
setFormError(null);
setOpen(true);
@@ -175,6 +178,7 @@ export function AiRouterConfig() {
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
models,
behavesAs: form.behavesAs.trim(),
};
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
@@ -370,6 +374,15 @@ export function AiRouterConfig() {
mono
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<Field
id="router-behaves-as"
label={t("aiRouter.fieldBehavesAs")}
hint={t("aiRouter.fieldBehavesAsHint")}
value={form.behavesAs}
mono
placeholder="claude-opus-5-5"
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
/>
<details className="rounded-lg border border-border bg-surface-2 p-2">
<summary className="cursor-pointer text-xs text-fg-secondary">
+4 -25
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as four pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — language —, `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s (Sagi has its own page, `components/settings/PetSettings.tsx`). Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -205,12 +205,12 @@ function useCountUp(end: number | null, durationMs = 1000) {
// ─── Main component ───
export function Settings({
section = "general",
section = "notifications",
}: {
/** Which Settings page this renders (`/settings/<section>`). */
section?: "general" | "data" | "about" | "notifications";
section?: "data" | "about" | "notifications";
} = {}) {
const { t, i18n } = useTranslation("settings");
const { t } = useTranslation("settings");
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
@@ -541,27 +541,6 @@ export function Settings({
</div>
)}
{section === "general" && (
<>
<SettingsSection title={t("language.title")} description={t("language.description")}>
<SettingsRow
label={t("language.label")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
onChange={(v) => void i18n.changeLanguage(v)}
options={[
{ value: "en" as const, label: t("language.names.en") },
{ value: "vi" as const, label: t("language.names.vi") },
]}
/>
}
/>
</SettingsSection>
</>
)}
{section === "notifications" && (
<>
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p>
+27 -29
View File
@@ -9,7 +9,8 @@
* element with that `id` or `data-settings-anchor` and briefly highlights it.
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to General. A tool's own settings live under `agents/<id>` — Claude Code and
* and falls back to Appearance (which also holds the language and the pet;
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
@@ -32,18 +33,12 @@ import {
Database,
Info,
Bot,
Boxes,
Coins,
GitBranch,
KeyRound,
Orbit,
Palette,
PawPrint,
Puzzle,
Router,
Search,
Settings as SettingsIcon,
SquareTerminal,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -52,7 +47,6 @@ import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { PetSettings } from "../components/settings/PetSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -64,6 +58,7 @@ import {
type SettingsEntry,
} from "../lib/settingsSearch";
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
@@ -80,7 +75,7 @@ interface Section {
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = {
dashboard: "general",
dashboard: "appearance",
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-router",
@@ -104,13 +99,29 @@ const historyIdx = (): number => {
export const backDelta = (current: number, entry: number): number =>
-Math.max(1, current - entry + 1);
/** An unknown section: say so, then show General. */
/** Sections folded into Appearance: old links and bookmarks still land. */
/** A tool's own mark as a nav icon, one stable component per tool id. */
const navIcons = new Map<string, ComponentType<{ className?: string }>>();
function toolNavIcon(id: string, label: string) {
let Icon = navIcons.get(id);
if (!Icon) {
Icon = ({ className }) => (
<AiToolIcon tool={{ id, label }} className={`${className ?? ""} !bg-transparent !p-0`} />
);
navIcons.set(id, Icon);
}
return Icon;
}
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to="/settings/general" replace />;
return <Navigate to="/settings/appearance" replace />;
}
export function SettingsHub() {
@@ -191,13 +202,6 @@ export function SettingsHub() {
description: t(`settingsNav.section.${key}.description`),
});
const sections: Section[] = [
{
id: "general",
group: "general",
Icon: SettingsIcon,
...s("general"),
render: () => <Settings section="general" />,
},
{
id: "data",
group: "general",
@@ -219,13 +223,6 @@ export function SettingsHub() {
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "pet",
group: "general",
Icon: PawPrint,
...s("pet"),
render: () => <PetSettings />,
},
{
id: "notifications",
group: "general",
@@ -245,7 +242,7 @@ export function SettingsHub() {
{
id: "agents/claude-code",
group: "agents" as const,
Icon: Boxes,
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
render: () => <CcConfig />,
},
@@ -256,7 +253,7 @@ export function SettingsHub() {
{
id: "agents/codex",
group: "agents" as const,
Icon: SquareTerminal,
Icon: toolNavIcon("codex", "Codex"),
...s("codex"),
render: () => <CodexConfig />,
},
@@ -269,7 +266,7 @@ export function SettingsHub() {
.map((x) => ({
id: `agents/${x.id}`,
group: "agents" as const,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
Icon: toolNavIcon(x.id, x.label),
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
@@ -349,9 +346,10 @@ export function SettingsHub() {
? "agents"
: tool
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
: "general";
: "appearance";
return <Navigate to={`/settings/${to}`} replace />;
}
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
+15 -53
View File
@@ -61,8 +61,6 @@ import {
PanelLeftClose,
PanelRightClose,
Maximize2,
FolderTree,
GitCompare,
ChartColumn,
} from "lucide-react";
import { api } from "../lib/api";
@@ -120,17 +118,9 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the right panel's activity-bar tab. */
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
const PANEL_TABS = [
{ id: "files", Icon: FolderTree },
{ id: "changes", Icon: GitCompare },
] as const;
type PanelTab = (typeof PANEL_TABS)[number]["id"];
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
@@ -600,21 +590,6 @@ export function Workspace() {
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
try {
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
} catch {
return "changes";
}
});
useEffect(() => {
try {
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
} catch {
/* the panel just forgets its tab between visits */
}
}, [panelTab]);
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
// order (client/src/lib/laneOrderStorage.ts).
@@ -1231,36 +1206,17 @@ export function Workspace() {
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Activity bar: Files · Changes · Sessions, then the resize
control — the same height as the console's tab bar, so the
two read as one header strip. */}
{/* Header: the title, Analyst, then the resize control — the
same height as the console's tab bar, so the two read as
one header strip. Files and Changes are ONE panel now; its
own mode switcher moves between them. */}
<div
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
role="toolbar"
>
{PANEL_TABS.map(({ id, Icon }) => (
<button
key={id}
type="button"
data-testid={`panel-tab-${id}`}
aria-pressed={panelTab === id}
// Narrow, a tab switch leaves the open file (and its
// edit); at full width the editor stays put.
onClick={() =>
panelTab !== id &&
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
}
title={tLanes(`panel.${id}`)}
aria-label={tLanes(`panel.${id}`)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
panelTab === id
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
<span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")}
</span>
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
@@ -1318,8 +1274,8 @@ export function Workspace() {
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
tab={panelTab}
full={changesFull}
wide={changesPreset !== "compact"}
onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
@@ -1355,7 +1311,13 @@ export function Workspace() {
{tLanes("changes.title")}
</span>
</button>
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
{panelAt && (
<LaneGitRailCounts
laneId={panelAt.laneId}
session={panelAt.session}
onShowAll={() => setChangesOpen(true)}
/>
)}
</div>
))}
</div>
@@ -104,11 +104,30 @@ describe("AiRouterConfig", () => {
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
+2 -6
View File
@@ -66,12 +66,8 @@ const burst = () => {
};
describe("Settings", () => {
it("never reads server info on Notifications or General, even on activity", async () => {
const { unmount } = render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
unmount();
render(<Settings section="general" />);
it("never reads server info on Notifications, even on activity", async () => {
render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled();
+29 -20
View File
@@ -22,6 +22,9 @@ vi.mock("../Settings", () => ({
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../../components/settings/PetSettings", () => ({
PetSettings: () => <div data-testid="pet-settings" />,
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
vi.mock("../AiToolConfig", () => ({
@@ -114,7 +117,9 @@ describe("SettingsHub", () => {
});
it.each([
["/settings", "/settings/general"],
["/settings", "/settings/appearance"],
["/settings/general", "/settings/appearance"],
["/settings/pet", "/settings/appearance"],
["/settings?tool=codex", "/settings/agents/codex"],
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
@@ -125,10 +130,12 @@ describe("SettingsHub", () => {
expect(screen.getByTestId("where").textContent).toBe(to);
});
it("sends an unknown section to General once the lists have loaded", async () => {
it("sends an unknown section to Appearance once the lists have loaded", async () => {
renderAt("/settings/nope");
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/appearance");
});
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
@@ -145,8 +152,8 @@ describe("SettingsHub", () => {
});
it("navigates from the mobile select", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
target: { value: "pricing" },
});
@@ -156,8 +163,8 @@ describe("SettingsHub", () => {
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole("searchbox", { name: "Search settings" });
await user.type(box, "pricing");
const links = within(nav())
@@ -177,8 +184,8 @@ describe("SettingsHub", () => {
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
const box = screen.getByRole("searchbox", { name: "Search settings" });
expect(box).toHaveFocus();
@@ -187,8 +194,8 @@ describe("SettingsHub", () => {
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
@@ -204,8 +211,8 @@ describe("SettingsHub", () => {
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
@@ -217,8 +224,8 @@ describe("SettingsHub", () => {
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!);
@@ -227,14 +234,16 @@ describe("SettingsHub", () => {
);
});
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
expect(links).not.toContain("General");
expect(links).not.toContain("Pet");
});
it("scrolls to and highlights the anchor the URL names", async () => {
@@ -258,11 +267,11 @@ describe("SettingsHub", () => {
expect(backDelta(0, 2)).toBe(-1);
});
it("toasts when an unknown section falls back to General", async () => {
it("toasts when an unknown section falls back to Appearance", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("settings-general");
await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});
@@ -6832,74 +6832,6 @@ exports[`screen snapshots > Sessions 1`] = `
</div>
`;
exports[`screen snapshots > Settings 1`] = `
<div>
<div
class="animate-fade-in space-y-8"
>
<section
class="card p-5"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Language
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
The language of the whole dashboard, remembered in this browser.
</p>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Interface language
</p>
</div>
<div
class="shrink-0"
>
<div
aria-label="Interface language"
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
role="radiogroup"
>
<button
aria-checked="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"
role="radio"
tabindex="0"
type="button"
>
English
</button>
<button
aria-checked="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"
role="radio"
tabindex="-1"
type="button"
>
Tiếng Việt
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
`;
exports[`screen snapshots > Settings — about 1`] = `
<div>
<div
@@ -6981,7 +6913,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.1.0 (rebuild client to match)
UI build v2.3.1 (rebuild client to match)
</p>
</div>
<div
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { render, act, screen, fireEvent, within } from "@testing-library/react";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next";
@@ -593,13 +593,9 @@ describe("screen snapshots", () => {
it("Run", async () => {
await snapshot(<Workspace />, "/run");
});
it("Settings", async () => {
await snapshot(<Settings />, "/settings");
});
it("Settings — notifications", async () => {
await snapshot(<Settings section="notifications" />, "/settings/notifications");
});
// General split into General / Data / About: keep the moved content covered.
it("Settings — data", async () => {
await snapshot(<Settings section="data" />, "/settings/data");
});
@@ -611,29 +607,6 @@ describe("screen snapshots", () => {
});
});
// The language picker moved off the sidebar into Settings → General.
describe("Settings — language", () => {
it("switches the interface language from General", async () => {
render(
<MemoryRouter initialEntries={["/settings/general"]}>
<Settings section="general" />
</MemoryRouter>
);
await settle();
const group = screen.getByRole("radiogroup", { name: "Interface language" });
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
"aria-checked",
"true"
);
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
await settle();
expect(i18n.language).toBe("vi");
await act(async () => {
await i18n.changeLanguage("en");
});
});
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.
+4
View File
@@ -49,6 +49,8 @@ const tinted = {
danger: tint("--status-danger"),
warning: tint("--status-warning"),
},
diff: { mod: tint("--diff-mod") },
kept: tint("--kept"),
};
/** @type {import('tailwindcss').Config} */
@@ -104,6 +106,8 @@ export default {
danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <alpha-value>)",
},
diff: { mod: "rgb(var(--diff-mod) / <alpha-value>)" },
kept: "rgb(var(--kept) / <alpha-value>)",
},
backgroundColor: tinted,
borderColor: tinted,
+37 -6
View File
@@ -491,7 +491,10 @@ One route, three depths — the shapes the Workspace's Changes panel asks for:
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
untracked files included, `branch` is `<base>...HEAD` for the workspace's
`base_branch`, and `commit` shows the single commit named by `sha` (an
unresolvable one is `400 EBADREV`, never an empty diff).
unresolvable one is `400 EBADREV`, never an empty diff). A `branch` answer also
carries `mergeBase` — the full sha of `git merge-base <baseRef> HEAD`, the
commit the diff is taken from (`null` on the other ranges or when git cannot
find one); the panel's editor reads a file's original text there.
`context` defaults to **3** and is clamped to **5000** — at the top of that
range the diff has effectively become the whole file, which is what "expand
@@ -509,6 +512,7 @@ that file.
"range": "work",
"base": "development",
"baseRef": "origin/development",
"mergeBase": null,
"sha": null,
"context": 3,
"truncated": false,
@@ -570,7 +574,8 @@ and file explorer. The GETs are read-only and unguarded, and the JSON ones
answer `{available:false}` with HTTP **200** for a cwd git cannot read.
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
refs, parents}]}`, newest first in `--date-order` (no parent before its
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
@@ -614,7 +619,7 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
ignore (`ls-files --cached --others --exclude-standard`), directories folded
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the workspace.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Changes panel's Files mode's **Expand all folders** is this one request instead of one per folder.
#### Read a workspace's own branches
@@ -655,6 +660,22 @@ reason is gone) is cleared first. A live pane still holding the session is
`409 ELIVE`; no `confirm` is `400 ECONFIRM`; a session with no worktree here
`400 ENOWORKTREE`. Answers `{removed: true, branch, branchKept}`.
#### Suggest a commit message
```http
POST /api/lanes/:id/git/commit-message
```
The Changes panel's ✨ button beside the commit box. Runs the user's own
`claude` CLI — `claude -p --safe-mode --no-session-persistence --tools "" --model haiku`
— on the **staged** diff of the workspace's cwd (or `?session=` worktree), sent on
stdin together with the last ten commit subjects so the message follows the
repo's style. Only reads: no tool, hook or plugin runs, no transcript is saved,
and a `settings.local.json` is never part of the diff. Same-origin, because
each call spends the user's Claude quota. Answers `{"message": "..."}`;
`400 ENOSTAGED` with nothing staged, `400 ENOTOOL` without `claude` on PATH,
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
#### Act on a workspace's working copy
```http
@@ -1991,6 +2012,14 @@ The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]`
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
```
#### Get Plan Quota
```
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
---
### Notifications
@@ -2369,7 +2398,7 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
```
GET /api/ai-routers Every saved profile, the built-in `native` one first
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
PATCH /api/ai-routers/:name Update the same fields, all optional
DELETE /api/ai-routers/:name Delete one saved profile
@@ -2379,6 +2408,8 @@ A **router profile** is an endpoint that stands in front of Claude Code or Codex
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the router's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the router really sends a name to.
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude workspace's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
@@ -2389,7 +2420,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
**Starting a session on a router.** `POST /api/lanes/:id/start` and `POST /api/run` take `router: "<name>"` (omitted, `""` or `"native"` = the vendor's own API). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADROUTER`, a router on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no router goes back to the one its session last ran on (`dashboard_runs.router`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_ROUTER_KEY` through tmux's own `-e`, never as typed text.
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
@@ -2405,7 +2436,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
```
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens **Settings → Pet** (`/settings/pet`, `client/src/components/settings/PetSettings.tsx`): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
+3 -3
View File
@@ -155,7 +155,7 @@ classDiagram
## 4) Language Detection and Switching Flow
The language picker in Settings → General (`client/src/pages/Settings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
The language picker in Settings → Appearance (`client/src/components/settings/AppearanceSettings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
```mermaid
sequenceDiagram
@@ -237,7 +237,7 @@ npm run test:client
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
| Runtime switching | UI rerenders without reload | Settings → General language picker |
| Runtime switching | UI rerenders without reload | Settings → Appearance language picker |
---
@@ -282,7 +282,7 @@ gantt
### Operational checklist
- [ ] Confirm all namespaces exist for `en` and `vi`
- [ ] Confirm key parity across all locale JSON files
- [ ] Confirm language switching from Settings → General re-renders the whole app
- [ ] Confirm language switching from Settings → Appearance re-renders the whole app
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
- [ ] Confirm date/time/currency formatting for all supported languages
- [ ] Confirm client tests pass before release
+50 -29
View File
@@ -396,7 +396,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub workspace add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering** — drag a row and drop it anywhere in the list (a line shows where it lands, by the pointer's position — on another row, on a session list, above the first or below the last), or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub workspaces` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
@@ -406,7 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
@@ -492,8 +492,9 @@ curl http://localhost:4820/api/lanes/5/git
read off disk, since a workspace mid-build can have a 100 MB artifact), a **binary**
file, a **rename** (numstat keys those differently), or a repo with no commit
to diff against yet.
- Hovering (or focusing, or tapping) the `N modified · N untracked` text at the
top of the Changes panel opens this list as a panel: one row per file, its status letter
- Hovering (or focusing, or tapping) the vertical `N modified · N untracked` chip
on the CLOSED Changes rail opens this list as a panel (the open panel shows no
counts — its own list below says the same): one row per file, its status letter
colour-coded, the directory truncated before the filename ever is, and each
row's `+N −M` aligned on the right under a total.
- A repo with no commit yet is **not** unavailable: it reports `unborn: true`,
@@ -600,23 +601,20 @@ persists per viewer in `localStorage`). Its header holds **one** control, which
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
height-locked so the panel fills the viewport even with no live run; the whole
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
restores the saved text) → closed (`agenthub.workspace.changesSize`
is dropped; a file opens on its **Changes** like at any width, and
**Whole file** is the editor — **Save**/**Cancel** wake up once the text
differs, Cancel restores the saved text) → closed (`agenthub.workspace.changesSize`
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
narrow. Between the console and the open (not full-screen) panel sits a **splitter**: drag it, or move it with the arrow keys, to any width between 15% and 85%; that fraction is stored as a NUMBER in the same `agenthub.workspace.changesSize` key, and the header button still steps through the presets. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
which one is "work" changes several times an hour, so the width is a step, not
a setting — and one button beats a width button wedged beside a collapse button
in the panel's tightest corner. It is icon-only, and the icon it draws is the
width the next click LANDS on (even split → wider → closed), with the wording
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
of two icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
**Files** (the browse mode below, on its own — no git header, no mode
switcher) and **Changes** (the git header and the other four modes). The
workspace's sessions live in the sidebar and on its home only — the panel's
former Sessions tab was a third copy of the same list. At Full screen the Files and Changes tabs show the same thing: the
explorer sidebar beside the changes. It
left to its tooltip. Files and Changes are ONE panel: its mode switcher reads
**Changes** (the overview, where it opens) · **Files** · **Review** · **History**
(the former activity-bar tabs and `agenthub.workspace.panelTab` are gone). The
workspace's sessions live in the sidebar and on its home only. At Full screen
Files is the explorer sidebar beside the changes, so its button is dropped. It
reads the workspace's git surface in five modes:
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
@@ -629,19 +627,36 @@ reads the workspace's git surface in five modes:
one green with `U` (`A` added, `D` deleted and struck through, `R`
renamed), and a folder holding any of them gets an amber dot. The colours
come with the tree read, so **Refresh** re-reads them.
Opening a file entry shows the whole file (`/git/file`) for reading, at every
panel width; an image file (png, jpg, gif, webp, avif, bmp, ico, svg) is shown
as the picture (`/git/raw`), with no editor. **Clicking its text** (or Enter on it; any range but a commit)
turns that view into a text editor with the caret on the clicked line and
the scroll position kept — a click that only ends a drag-selection does not,
so copying never drops you into the editor. **Cancel** returns to reading.
From a diff, which has no whole text to click, an **Edit** button does the
same once the file has loaded. **Save** (or
A changed file opens on its **Changes**, also when picked in Files; an
unchanged file picked in Files opens as the whole file. Any working file
(not a commit's, a binary, a deleted or a conflicted one) is the **editor**
in both views (`client/src/components/lanes/DiffEditor.tsx`, CodeMirror 6
with `@codemirror/merge`, lazy-loaded), editable, with its diff against
HEAD (`work`) or the branch's merge base (`branch`, `mergeBase` on
`/git/changes`): **Changes** folds the unchanged stretches away (a click on
a fold opens it), **Whole file** shows them, and the toggle keeps the draft.
Unified puts the removed lines read-only above their replacement; side by
side puts the original, read-only, left of the file. Every changed block
carries a **Revert** (`→` between split columns) that puts the original back
in the editor — nothing is written until Save. The panel opens side by side
at 50/50 and full width (Workspace's `wide`) and unified when narrower; the
layout button overrides it until the next resize. The colours follow every
keystroke: **green** is a line added whole, **red** a line removed whole,
**blue** a line edited in place (it keeps a letter or digit of its old
text), its edit in a stronger blue; the read-only diffs (Review, a commit, a
file the editor cannot take) use the same scheme, and only they carry the
per-block Stage/Undo review bars and the line-comment buttons — Review
keeps both for every file. In a read-only side-by-side diff, every row is at
least one line tall (`MIN_ROW`), so an empty filler row keeps the two
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
shown as the picture (`/git/raw`), and a conflicted file opens in the
conflict view. **Cancel** reverts unsaved edits. **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
line endings and final newline. A file the editor cannot take (binary, or
over the 1 MB view cap) says so in the file's header.
line endings and final newline; the editor stays open on what was saved. A
file the editor cannot take (binary, or over the 1 MB view cap) says so in
the file's header.
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons.
@@ -675,8 +690,9 @@ reads the workspace's git surface in five modes:
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
and landing back at its top is the whole cost of opening one file.
- **History** — the workspace's recent commits (`GET /:id/git/log`); opening one
shows that commit's diff (`?range=commit&sha=`).
- **History** — the workspace's recent commits (`GET /:id/git/log`) drawn as a
commit graph, one coloured lane per branch line (`client/src/lib/commitGraph.ts`);
opening one shows that commit's diff (`?range=commit&sha=`).
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
working tree, untracked files included as an all-added patch) and **vs base**
@@ -712,14 +728,19 @@ overview grows a commit box the moment anything is staged — above the list and
pinned while it scrolls, on ONE row with a **Stage all** that stages every listed file
the index lacks in one call and an **Unstage all** that unstages every staged one — it sends
`only: "staged"`, never `add -A`, and it is absent with a clean index because
there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
there is nothing it could mean. A file you never want committed (a local note, a scratch folder) can be marked **Never stage** (its row's eye-off button, or right-click): **Stage all** and a folder's stage button then skip it, the row is struck through, and the file is drawn orange in the Files tree (`--kept`, apart from the amber of a modified file). The list is kept in this browser per workspace (`agenthub.changes.neverStage.<lane id>`) and git is never told — nothing is written to the repo, and the file's own Stage still works. It is the dashboard's ONLY commit: the git bar
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
irreversible action in the panel: it asks twice in the UI, and the server
refuses it without `confirm: true` and accepts only paths `git status` is
already reporting as changed. In the `work` range the overview splits like VS
Code's Source Control: **Staged Changes** (what the next commit takes, each row
offering Unstage) above **Changes** (unstaged and untracked, each offering
Stage); a partly staged file appears in both. In the tree, a changed file's
Stage); a partly staged file appears in both. Each group is a folder tree like
JetBrains' commit view (`client/src/lib/changeTree.ts`): folders first, a
chain of folders holding only one sub-folder compacted into one row
(`src/lib`), each folder row showing its file count, folding on a click and —
in Staged Changes / Changes — carrying one button that unstages / stages every
file under it; the group headers fold too. The fold state is not stored. In the tree, a changed file's
whole row takes its status tint, not only its letter.
Review, single-file, history and browse do **not** poll: re-fetching under the
+83 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.1.0
version: 2.3.1
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ĩ
@@ -10630,6 +10630,50 @@ paths:
error:
code: EBADORIGIN
message: cross-origin requests are not allowed
/api/run/history/{id}:
delete:
tags:
- Run
summary: Forget a closed Terminal tab
description: Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.
operationId: runHistoryForget
parameters:
- name: id
in: path
required: true
schema:
type: string
description: The history row's id (RunHistoryItem.id).
example: agenthub-lane-5-2@2026-10-06T08:00:00.000Z
responses:
'200':
description: Removed
content:
application/json:
example:
removed: agenthub-lane-5-2
'403':
description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
code: EBADORIGIN
message: cross-origin requests are not allowed
'404':
description: No closed Terminal tab with that id (ENOTFOUND)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: That terminal is still open (ERUNLIVE)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/cwds:
get:
tags:
@@ -11594,6 +11638,44 @@ paths:
description: '{available, branches, current, worktrees}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/commit-message:
post:
tags:
- Lanes
summary: Suggest a commit message for the staged changes
description: Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools "" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.
operationId: suggestLaneCommitMessage
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: session
in: query
required: false
schema:
type: string
responses:
'200':
description: The suggested message
content:
application/json:
schema:
type: object
properties:
message:
type: string
required:
- message
'400':
description: ENOSTAGED / ENOTOOL / ENOWORKTREE
'403':
description: Cross-origin request refused
'404':
description: No such workspace
'502':
description: EAI — the claude CLI failed
/api/lanes/{id}/git/{action}:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.1.0",
"version": "2.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.1.0",
"version": "2.3.1",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.1.0",
"version": "2.3.1",
"bin": {
"agenthub": "bin/agenthub.js"
},
+6 -4
View File
@@ -26,6 +26,8 @@ const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
const { failureText } = require("./plugin-update-helper");
const [, , runtimeDirArg, planJson] = process.argv;
@@ -229,7 +231,7 @@ function main() {
} catch (err) {
// A marketplace that can't be refreshed (offline, auth) does not stop the
// batch: the plugins may still have updates from the marketplaces that did.
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
finishStep(mktStep, "failed", { error: failureText(err).slice(0, 2000) });
}
const self = plan.selfPlugin;
@@ -243,7 +245,7 @@ function main() {
run(claude, ["plugin", "update", id, "-y"], 600_000);
finishStep(step, "ok");
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
}
}
@@ -253,7 +255,7 @@ function main() {
try {
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
reloadPanes();
state.status = "failed";
state.finished_at = new Date().toISOString();
@@ -281,7 +283,7 @@ function main() {
} catch (err) {
log(`bootstrap failed: ${err.message}`);
state.status = "failed";
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
state.bootstrap_error = failureText(err).slice(0, 2000);
state.note = "the old server was stopped; run /agenthub-update to recover";
flush();
}
+59 -20
View File
@@ -23,7 +23,10 @@
* runtime directory (outside the plugin cache, so it survives the swap) and the
* new server reports it back as `last_plugin_update`. Without that, a failed
* chain would look identical to a slow one: the tab would poll a server that
* never comes back and say nothing about why.
* never comes back and say nothing about why. A failed step's verdict carries
* what the command printed (`failureText`): `plugin-bootstrap.js` says why it
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
* only "Command failed: <argv>".
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -35,8 +38,10 @@ const { execFileSync, spawnSync } = require("child_process");
const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
const RESULT_FILE = path.join(runtimeDirArg, "plugin-update.json");
const LOG_FILE = path.join(runtimeDirArg, "plugin-update.log");
// `|| ""`: required by a test (for `failureText`) there is no argv, and only
// main() — which a require never runs — writes these.
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
// Names come from the local cache PATH, never from an HTTP body, but they still
// reach an argv — so they are re-validated here rather than trusted across the
@@ -120,14 +125,44 @@ function run(cmd, args, timeout) {
function runOnce(cmd, args, timeout) {
log(`$ ${cmd} ${args.join(" ")}`);
const out = execFileSync(cmd, args, {
encoding: "utf8",
timeout,
maxBuffer: 10_000_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (out) log(out.trim().slice(0, 4000));
return out;
try {
const out = execFileSync(cmd, args, {
encoding: "utf8",
timeout,
maxBuffer: 10_000_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (out) log(out.trim().slice(0, 4000));
return out;
} catch (err) {
log(`failed (${howItEnded(err)})`);
for (const stream of [err.stdout, err.stderr]) {
if (stream) log(String(stream).trim().slice(-4000));
}
throw err;
}
}
function howItEnded(err) {
if (err.signal) return `killed by ${err.signal}`;
return err.status != null ? `exit ${err.status}` : err.code || "did not run";
}
/**
* A failed command's verdict text: the last lines it printed on stdout and
* stderr, then how it ended. Falls back to execFileSync's message ("Command
* failed: …", or ENOENT) only when the command printed nothing.
*/
function failureText(err) {
const tail = (s) =>
String(s || "")
.trim()
.split("\n")
.slice(-5)
.join("\n");
const said = [tail(err.stdout), tail(err.stderr)].filter(Boolean);
if (!said.length) return `${String(err.message || err).split("\n")[0]} (${howItEnded(err)})`;
return [...said, `(${howItEnded(err)})`].join("\n");
}
function main() {
@@ -149,14 +184,14 @@ function main() {
try {
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
} catch (err) {
record({ ok: false, step: "marketplace_update", error: String(err.stderr || err.message) });
record({ ok: false, step: "marketplace_update", error: failureText(err) });
return;
}
try {
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
} catch (err) {
record({ ok: false, step: "plugin_update", error: String(err.stderr || err.message) });
record({ ok: false, step: "plugin_update", error: failureText(err) });
return;
}
@@ -201,7 +236,7 @@ function main() {
record({
ok: false,
step: "bootstrap",
error: String(err.stderr || err.message),
error: failureText(err),
plugin_root: newRoot,
note: "the old server was stopped; run /agenthub-update to recover",
});
@@ -211,10 +246,14 @@ function main() {
record({ ok: true, step: "done", plugin_root: newRoot });
}
try {
main();
} catch (err) {
// A helper that dies without a verdict is the failure mode this file exists
// to prevent, so even an unexpected throw gets recorded.
record({ ok: false, step: "unexpected", error: err.message || String(err) });
module.exports = { failureText };
if (require.main === module) {
try {
main();
} catch (err) {
// A helper that dies without a verdict is the failure mode this file exists
// to prevent, so even an unexpected throw gets recorded.
record({ ok: false, step: "unexpected", error: err.message || String(err) });
}
}
+1 -1
View File
@@ -530,7 +530,7 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the workspace. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. What git ignores at that level (read off the disk, `.git` excepted — also inside an ignored folder) is listed too with `ignored: true`; `tree-all` adds them per level but gives an ignored folder no level of its own. `400 EBADPATH` for a path escaping the workspace. |
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
+15
View File
@@ -164,6 +164,21 @@ describe("ai-routers — update", () => {
it("reports a name that does not exist", () => {
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
assert.equal(created.behavesAs, "claude-opus-5-5");
assert.equal(
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
"claude-opus-5-5"
);
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
code: "EBADMODEL",
});
}
});
});
describe("ai-routers — remove", () => {
+91
View File
@@ -0,0 +1,91 @@
/**
* @file Tests for server/lib/commit-message.js against a real git repo and a
* fake `claude` script: only staged, in-lane, non-secret changes reach the
* model; its argv keeps tools, hooks and persistence off; its answer is
* cleaned; and every failure is a coded error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { suggestCommitMessage, buildInput } = require("../lib/commit-message");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commitmsg-"));
const REPO = path.join(ROOT, "repo");
const SUB = path.join(REPO, "sub");
const ARGS = path.join(ROOT, "args.txt");
const STDIN = path.join(ROOT, "stdin.txt");
const g = (...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_INDEX_FILE;
execFileSync("git", ["-c", "user.email=t@h", "-c", "user.name=t", ...args], { cwd: REPO, env });
};
const write = (file, body) => {
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
fs.writeFileSync(path.join(REPO, file), body);
};
/** A fake claude: records argv and stdin, prints `reply`, exits `code`. */
function fakeClaude(reply, code = 0) {
const bin = path.join(ROOT, `claude-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(
bin,
`#!/bin/sh\nprintf '%s\\n' "$@" > '${ARGS}'\ncat > '${STDIN}'\nprintf '${reply}'\nexit ${code}\n`,
{ mode: 0o755 }
);
return bin;
}
before(() => {
fs.mkdirSync(REPO);
g("init", "-q", "-b", "main");
write("a.txt", "one\n");
g("add", "-A");
g("commit", "-q", "-m", "feat: seed");
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("commit-message", () => {
it("refuses with ENOSTAGED when nothing is staged", async () => {
write("a.txt", "one\nunstaged\n");
await assert.rejects(buildInput(REPO), { code: "ENOSTAGED" });
});
it("sends only staged, non-secret changes and keeps tools/hooks/persistence off", async () => {
write("b.txt", "staged line\n");
write(".claude/settings.local.json", '{"env":{"KEY":"sk-secret"}}\n');
g("add", "-f", "b.txt", ".claude/settings.local.json");
const msg = await suggestCommitMessage(REPO, { bin: fakeClaude("```\\nfeat: add b\\n```") });
assert.equal(msg, "feat: add b");
const stdin = fs.readFileSync(STDIN, "utf8");
assert.match(stdin, /feat: seed/);
assert.match(stdin, /staged line/);
assert.doesNotMatch(stdin, /unstaged|sk-secret|settings\.local/);
const argv = fs.readFileSync(ARGS, "utf8").split("\n");
for (const flag of ["-p", "--safe-mode", "--no-session-persistence", "--tools"]) {
assert.ok(argv.includes(flag), flag);
}
assert.equal(argv[argv.indexOf("--tools") + 1], "");
});
it("scopes a subdirectory lane to its own staged files", async () => {
write("sub/c.txt", "inside\n");
g("add", "sub/c.txt");
const input = await buildInput(SUB);
assert.match(input, /inside/);
assert.doesNotMatch(input, /staged line/);
});
it("reports a failing or silent CLI as EAI", async () => {
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
code: "EAI",
});
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("") }), { code: "EAI" });
});
});
+30
View File
@@ -52,6 +52,36 @@ describe("dashboard-runs", () => {
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
});
it("forgets a closed Terminal tab and nothing of another tool", () => {
start("agenthub-lane-9", { laneId: 9, tool: "claude" });
start("agenthub-lane-9-1", { laneId: 9, tool: "shell" });
assert.equal(runs.forgetShellRun("agenthub-lane-9"), false);
assert.ok(runs.getRun("agenthub-lane-9"));
assert.equal(runs.forgetShellRun("agenthub-lane-9-1"), true);
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
});
it("keeps only the newest closed Terminal tabs of a lane, live ones untouched", () => {
for (let i = 1; i <= 5; i++) {
start(`agenthub-lane-11-${i}`, {
laneId: 11,
tool: "shell",
startedAt: Date.parse(`2026-10-0${i}T10:00:00Z`),
});
}
start("agenthub-lane-11", { laneId: 11, tool: "claude", startedAt: Date.parse("2026-09-01") });
// Pane 5 still runs; every other one has closed.
runs.reconcileOrphans((id) => id === "agenthub-lane-11-5");
const shells = runs.listRuns({ laneId: 11, tool: "shell" }).map((r) => r.id);
assert.deepEqual(shells, [
"agenthub-lane-11-5",
"agenthub-lane-11-4",
"agenthub-lane-11-3",
"agenthub-lane-11-2",
]);
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
});
it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 });
+17 -2
View File
@@ -195,6 +195,7 @@ describe("collectChanges — branch range", () => {
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
assert.equal(out.baseRef, "origin/development");
assert.equal(out.mergeBase, g(LANE, "merge-base", "origin/development", "HEAD").trim());
assert.deepEqual(
out.files.map((f) => f.path),
["a.txt"]
@@ -401,6 +402,8 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
["touch keep", "touch a", "init"]
);
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
assert.deepEqual(all[0].parents, [all[1].sha]);
assert.deepEqual(all[2].parents, []);
const forA = await listCommits(LANE, { file: "a.txt" });
assert.deepEqual(
@@ -460,20 +463,32 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
write(LANE, "src/main.ts", "m\n");
write(LANE, ".gitignore", "*.log\n");
write(LANE, ".gitignore", "*.log\nvendor/\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "a.txt", "modified\n");
write(LANE, "new.txt", "fresh\n");
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
write(LANE, "noise.log", "ignored\n");
write(LANE, "vendor/lib/x.js", "ignored folder\n");
const root = await listTree(LANE, {});
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
assert.equal(by["a.txt"].status, " M");
assert.equal(by["new.txt"].status, "??");
assert.equal(by["keep.txt"].status, undefined);
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
assert.equal(by["noise.log"].ignored, true, "an ignored file is listed, marked");
assert.equal(by.vendor.ignored, true);
assert.equal(by.vendor.dir, true);
assert.equal(by[".git"], undefined, "git's own dir is never listed");
assert.equal(by["a.txt"].ignored, undefined);
assert.equal(by.src.ignored, undefined);
// Inside an ignored folder, which `ls-files --ignored` cannot list.
const lib = await listTree(LANE, { subdir: "vendor/lib" });
assert.deepEqual(
lib.entries.map((e) => [e.name, e.ignored]),
[["x.js", true]]
);
assert.equal(by.gen.changed, true);
assert.equal(by.src.changed, undefined);
+2 -1
View File
@@ -185,7 +185,8 @@ describe("lane-sources instructions and launch", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
+5
View File
@@ -1105,6 +1105,11 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
assert.equal(bogus.status, 400);
assert.equal(bogus.body.error.code, "EBADACTION");
// Its own route, not an action: never reaches claude with nothing staged.
const suggest = await request("POST", `/api/lanes/${laneId}/git/commit-message`, {});
assert.equal(suggest.status, 400);
assert.equal(suggest.body.error.code, "ENOSTAGED");
});
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
+33
View File
@@ -169,3 +169,36 @@ describe("plugin-update-helper name guard", () => {
assert.ok(!Number.isNaN(Date.parse(v.finished_at)));
});
});
describe("plugin-update-helper failureText", () => {
// Required, not spawned: the helper only runs main() as a script.
const { failureText } = require("../../scripts/plugin-update-helper");
it("names why the bootstrap refused, which it prints on stdout", () => {
const err = Object.assign(new Error("Command failed: node plugin-bootstrap.js --force"), {
status: 1,
stdout: "installing…\nbootstrap: locked\n",
stderr: "",
});
assert.equal(failureText(err), "installing…\nbootstrap: locked\n(exit 1)");
});
it("keeps only the last lines, stdout then stderr", () => {
const err = Object.assign(new Error("x"), {
status: 2,
stdout: ["1", "2", "3", "4", "5", "6", "7"].join("\n"),
stderr: "boom",
});
assert.equal(failureText(err), "3\n4\n5\n6\n7\nboom\n(exit 2)");
});
it("says a killed command was killed, and falls back to the message when nothing was printed", () => {
const err = Object.assign(new Error("Command failed: node b.js --force\n"), {
status: null,
signal: "SIGTERM",
stdout: "",
stderr: "",
});
assert.equal(failureText(err), "Command failed: node b.js --force (killed by SIGTERM)");
});
});
+45 -15
View File
@@ -75,11 +75,9 @@ describe("pty-run", () => {
const { spawn } = require("node:child_process");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
const flag = path.join(bin, "attached");
fs.writeFileSync(
path.join(bin, "tmux"),
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
{ mode: 0o755 }
);
fs.writeFileSync(path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, {
mode: 0o755,
});
try {
const t0 = Date.now();
const child = spawn("sh", ["-c", argv[2]], {
@@ -492,11 +490,43 @@ describe("pty-run", () => {
return calls.find((c) => c[0] === "new-session");
}
/** The pane's own command line — everything tmux passes after the `--`. */
/** The CLI's own command line: what tmux passes after the `--`, less the
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
function paneArgv(newSessionCall) {
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
assert.match(argv[2], /session_attached.*exec "\$@"$/);
return argv.slice(4);
}
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
// A claude drawn at tmux's birth size duplicated its banner on the first
// resize; the argv rides as positional parameters, never script text.
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
);
const argv = paneArgv(call);
assert.ok(argv.includes("a'; rm -rf / #"));
const { execFileSync } = require("node:child_process");
const out = execFileSync(
"sh",
[
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
"sh",
"printf",
"%s|",
"x y",
"$HOME",
],
{
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
encoding: "utf8",
timeout: 5000,
}
);
assert.equal(out, "x y|$HOME|");
});
it("spawnRun builds a codex argv for a codex lane", () => {
const call = captureNewSession(() =>
pty.spawnRun({
@@ -583,15 +613,11 @@ describe("pty-run", () => {
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
});
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
it("leaves a claude pane's screen mode to the user's own tui setting", () => {
// Forcing it inline duplicated the transcript on every pane narrowing.
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
tmux.__reset();
const codex = captureNewSession(() =>
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
);
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
assert.ok(!envFlags(claude).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
});
describe("catalog tools", () => {
@@ -719,7 +745,11 @@ describe("pty-run", () => {
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
live = true;
t.mock.timers.tick(3000);
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
// waiting for a client to attach.
t.mock.timers.tick(4999);
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
t.mock.timers.tick(1);
assert.deepEqual(
calls.find((c) => c[0] === "set-buffer"),
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
+109
View File
@@ -0,0 +1,109 @@
/**
* @file Tests server/lib/quota.js: a rollout's `rate_limits`
* window mapped to the status bar's label/percent/reset, and a window that has
* reset since Codex last wrote reading as empty, and the Codex login's
* account + expiry read from `auth.json`; and a saved Claude profile asked
* with its own token only while it is valid, never refreshed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { test } = require("node:test");
const assert = require("node:assert");
const { codexWindow, windowLabel } = require("../lib/quota");
test("windowLabel names the plan windows", () => {
assert.strictEqual(windowLabel(300), "5h");
assert.strictEqual(windowLabel(10080), "wk");
assert.strictEqual(windowLabel(43200), "mo");
assert.strictEqual(windowLabel(2880), "2d");
});
test("codexWindow keeps a live window and empties a reset one", () => {
const future = Math.floor(Date.now() / 1000) + 3600;
assert.deepStrictEqual(
codexWindow({ used_percent: 27, window_minutes: 300, resets_at: future }, Date.now()),
{ label: "5h", usedPercent: 27, resetsAt: new Date(future * 1000).toISOString() }
);
const past = codexWindow(
{ used_percent: 92, window_minutes: 10080, resets_in_seconds: 60 },
Date.now() - 120_000
);
assert.strictEqual(past.usedPercent, 0);
assert.strictEqual(codexWindow(null, 0), null);
});
test("codexAccount reads the email and access-token expiry, never a token", () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { codexAccount } = require("../lib/quota");
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-codex-"));
const prev = process.env.CODEX_HOME;
process.env.CODEX_HOME = home;
try {
assert.deepStrictEqual(codexAccount(), {});
fs.writeFileSync(
path.join(home, "auth.json"),
JSON.stringify({
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
})
);
assert.deepStrictEqual(codexAccount(), {
account: "a@b.c",
expiresAt: new Date(1_800_000_000_000).toISOString(),
});
} finally {
if (prev === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prev;
fs.rmSync(home, { recursive: true, force: true });
}
});
test("a saved profile is asked with its own token only while valid, never refreshed", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-claude-"));
const dir = path.join(home, "agenthub-profiles");
fs.mkdirSync(dir);
const profile = (name, token, expiresAt) =>
fs.writeFileSync(
path.join(dir, `${name}.json`),
JSON.stringify({
label: name,
account: { accountUuid: name, emailAddress: `${name}@x.y` },
credentials: { accessToken: token, refreshToken: "r", expiresAt },
})
);
profile("fresh", "tok-fresh", Date.now() + 3600_000);
profile("stale", "tok-stale", Date.now() - 1000);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
const asked = [];
process.env.CLAUDE_HOME = home;
global.fetch = async (url, opts) => {
asked.push(`${url} ${opts.headers.Authorization}`);
return {
status: 200,
ok: true,
json: async () => ({ five_hour: { utilization: 40, resets_at: null } }),
};
};
try {
__resetCache();
const claude = (await quotas()).tools.filter((t) => t.id === "claude");
assert.deepStrictEqual(asked, ["https://api.anthropic.com/api/oauth/usage Bearer tok-fresh"]);
const by = Object.fromEntries(claude.map((t) => [t.label, t]));
assert.strictEqual(by.fresh.active, false);
assert.strictEqual(by.fresh.windows[0].usedPercent, 40);
assert.strictEqual(by.stale.error, "EPROFILEEXPIRED");
assert.strictEqual(by.stale.account, "stale@x.y");
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(home, { recursive: true, force: true });
}
});
+27
View File
@@ -119,6 +119,33 @@ describe("routerForSpawn — Claude", () => {
it("adds nothing for native", () => {
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
});
it("writes no modelPicker without behavesAs, so the user's own one still applies", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
assert.equal("modelPicker" in written, false);
});
it("maps every distinct router model name with behavesAs (issue #56)", () => {
const { name } = routers.createRouter({
label: "9Router combo",
baseUrl: "http://localhost:20128/v1",
model: "agent",
models: { haiku: "agent-fast", subagent: "claude-haiku-4-5-20251001" },
behavesAs: "claude-opus-5-5",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
// A bare claude-* id is one Claude Code knows; mapping it would override
// its real handling.
assert.deepEqual(written.modelPicker, {
options: [
{ model: "agent", behavesAs: "claude-opus-5-5" },
{ model: "agent-fast", behavesAs: "claude-opus-5-5" },
],
});
});
});
describe("routerForSpawn — Codex", () => {
+1 -1
View File
@@ -59,7 +59,7 @@ describe("tmux wrapper", () => {
"-t",
"=agenthub-lane-1:",
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
"fg=brightwhite,bg=black,width=1,pad=0",
]);
});
+11 -1
View File
@@ -91,7 +91,17 @@ describe("POST /api/updates/check", () => {
});
describe("POST /api/updates/apply", () => {
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async () => {
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async (t) => {
// Never let this test APPLY anything. When this checkout really is behind
// a release (or reports a version older than the newest tag), the route
// fast-forwards the repo, runs `npm run setup` — which under the shell's
// NODE_ENV=production prunes every dev dependency — and SIGTERMs the
// process to restart it. That happened once; ask first and stand down.
const status = await httpFetch("/api/updates/status");
if (status.body.update_available) {
t.skip(`this checkout is behind ${status.body.latest_release}; not applying it in a test`);
return;
}
// The test repo checkout has no update pending (or isn't fast-forwardable
// from a bare test run), so this exercises the decline path only — it must
// never touch the working tree or process in a test run.
+21 -1
View File
@@ -189,6 +189,21 @@ function normalizeModels(model, models) {
return out;
}
/**
* A known Claude model id that Claude Code should treat the router's own model
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
* The user picks it, never AgentHub: it also sets the context window Claude
* Code compacts at, and a router may route a name to a non-Claude model.
*/
function normalizeBehavesAs(value) {
const clean = typeof value === "string" ? value.trim() : null;
if (clean === "") return "";
if (!clean || !/^claude-[a-z0-9][a-z0-9.-]{0,99}$/.test(clean)) {
throw err("EBADMODEL", "behavesAs must be a Claude model id, e.g. claude-opus-5-5");
}
return clean;
}
function assertNotBuiltin(name) {
if (name === BUILTIN_NAME) {
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
@@ -250,7 +265,7 @@ function getRouter(name) {
/**
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
* models?:Record<string,string>}} input
* models?:Record<string,string>, behavesAs?:string}} input
* @returns the browser-safe view of what was saved
*/
function createRouter(input = {}) {
@@ -265,6 +280,7 @@ function createRouter(input = {}) {
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
model,
models: normalizeModels(model, input.models),
behavesAs: input.behavesAs === undefined ? "" : normalizeBehavesAs(input.behavesAs),
savedAt: new Date().toISOString(),
};
@@ -297,6 +313,10 @@ function updateRouter(name, changes = {}) {
changes.model === undefined && changes.models === undefined
? stored.models
: normalizeModels(model, changes.models ?? stored.models),
behavesAs:
changes.behavesAs === undefined
? (stored.behavesAs ?? "")
: normalizeBehavesAs(changes.behavesAs),
savedAt: new Date().toISOString(),
};
+1
View File
@@ -491,6 +491,7 @@ module.exports = {
readProfile,
writeProfile,
liveCredentialsFor,
readActiveAuth,
// exported for tests
profilesDir,
configPaths,
+121
View File
@@ -0,0 +1,121 @@
/**
* @file commit-message.js
* @description Suggests a commit message for a lane's STAGED changes with the
* user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with
* no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on
* disk (`--no-session-persistence`), so the call neither acts on the repo nor
* shows up as a session in the dashboard. Read-only on git; the diff is scoped
* to the lane's cwd and never carries a secret file (`isSecretOnDisk`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const { spawn } = require("node:child_process");
const worktree = require("./worktree");
const aiTools = require("./ai-tools");
const { isSecretOnDisk } = require("./git-diff");
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/** Diff characters sent to the model; the rest is cut and marked. */
const MAX_DIFF_CHARS = 60_000;
/** A `claude -p` that has not answered by then is killed. */
const TIMEOUT_MS = 120_000;
const PROMPT =
"Write a git commit message for the staged diff given on stdin. " +
"Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " +
"matching the style of the recent subjects listed there. Add a short body after a blank line only " +
"when the change needs explaining. Output ONLY the commit message — no code fences, no commentary.";
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
/** The text the model reads: recent subjects, the staged file list, the diff. */
async function buildInput(dir) {
const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
.split("\0")
.filter(Boolean);
const paths = [];
for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p);
if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace");
let diff;
try {
diff = (
await git(
dir,
["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths],
{ maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 }
)
).stdout;
} catch (err) {
// An oversized diff: git's partial output is still the useful head.
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
diff = String(err.git.stdout);
}
if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
const log = await git(dir, ["log", "-10", "--format=%s"])
.then((r) => r.stdout.trim())
.catch(() => ""); // no commit yet
return [
log ? `Recent commit subjects:\n${log}` : "",
`Staged files:\n${paths.join("\n")}`,
`Staged diff:\n${diff}`,
]
.filter(Boolean)
.join("\n\n");
}
/** Run `bin` with `args`, `input` on stdin; resolve its stdout. */
function run(bin, args, input) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
let out = "";
let errOut = "";
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (errOut += d));
child.on("error", (err) => {
clearTimeout(timer);
reject(bad("EAI", err.message));
});
child.on("close", (code, signal) => {
clearTimeout(timer);
if (code === 0) return resolve(out);
const why = signal ? "timed out" : (errOut || out).trim().slice(-500) || `exit ${code}`;
reject(bad("EAI", `claude failed: ${why}`));
});
child.stdin.on("error", () => {}); // a CLI that exits early closes stdin
child.stdin.end(input);
});
}
/**
* A suggested message for what `dir` has staged.
* @param {string} dir the lane's (or session worktree's) cwd
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
* @returns {Promise<string>}
*/
async function suggestCommitMessage(dir, { bin } = {}) {
const input = await buildInput(dir);
const claude = bin || aiTools.detect("claude", { fresh: true }).path;
if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH");
const out = await run(
claude,
["-p", "--safe-mode", "--no-session-persistence", "--tools", "", "--model", "haiku", PROMPT],
input
);
const message = out
.trim()
.replace(/^```[a-z]*\n?|\n?```$/g, "")
.trim();
if (!message) throw bad("EAI", "claude returned an empty message");
return message;
}
module.exports = { suggestCommitMessage, buildInput };
+27 -1
View File
@@ -165,6 +165,14 @@ function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
}
}
const forgetShellStmt = db.prepare(`DELETE FROM dashboard_runs WHERE id = ? AND tool = 'shell'`);
/** Drop a closed Terminal tab's row — a shell has no session, so the row is
* only the sidebar's "recently closed" entry. Never touches another tool's. */
function forgetShellRun(id) {
return forgetShellStmt.run(id).changes > 0;
}
function getRun(id) {
try {
return getStmt.get({ id }) || null;
@@ -255,12 +263,28 @@ const abandonStmt = db.prepare(`
WHERE id = ? AND status IN ('running', 'spawning')
`);
/** Closed Terminal tabs kept per lane — what the sidebar lists
* (`CLOSED_TERMINALS` in WorkspaceSessionList.tsx). */
const CLOSED_SHELLS_KEPT = 3;
// A shell has no session, so its row is only that list's entry: one the list
// cannot show is dropped, or removing the shown ones uncovers older ones.
const pruneShellsStmt = db.prepare(`
DELETE FROM dashboard_runs WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY lane_id ORDER BY started_at DESC) AS n
FROM dashboard_runs
WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
) WHERE n > ${CLOSED_SHELLS_KEPT}
)
`);
/**
* Flags `abandoned` every row still marked running/spawning whose pane is
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
* A pane is a tmux session, which outlives a server restart, so liveness is
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
* and on the server's run-heal beat.
* and on the server's run-heal beat, which also drops the closed Terminal
* tabs past the newest {@link CLOSED_SHELLS_KEPT} of each lane.
*
* Returns the number of rows updated.
*/
@@ -270,6 +294,7 @@ function reconcileOrphans(isAlive = () => false) {
for (const { id } of runningStmt.all()) {
if (!isAlive(id)) changes += abandonStmt.run(id).changes;
}
pruneShellsStmt.run();
return changes;
} catch {
return 0;
@@ -281,6 +306,7 @@ module.exports = {
patchRun,
listRuns,
getRun,
forgetShellRun,
reconcileOrphans,
routerForSession,
laneForSession,
+61 -10
View File
@@ -508,7 +508,7 @@ async function synthesizeAddedPatch(dir, file) {
* `file` is set, which is already a full read of one file.
* @param {number} [opts.context] Unchanged lines around each hunk. The panel
* raises this to expand a diff towards the whole file.
* @returns {Promise<{range, base, baseRef, sha, context, files, truncated}>}
* @returns {Promise<{range, base, baseRef, mergeBase, sha, context, files, truncated}>}
*/
async function collectChanges(
dir,
@@ -520,11 +520,17 @@ async function collectChanges(
const ctx = normalizeContext(context);
let baseRef = null;
// The commit a branch diff is taken from (`<baseRef>...HEAD`): the editor
// reads a file's original text there.
let mergeBase = null;
if (range === "branch") {
baseRef = await resolveBaseRef(dir, base);
if (!baseRef) {
throw badRequest("ENOBASE", "no base branch to compare against");
}
mergeBase = await git(dir, ["merge-base", baseRef, "HEAD"])
.then((r) => r.stdout.trim() || null)
.catch(() => null);
}
if (range === "commit") {
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision");
@@ -598,7 +604,7 @@ async function collectChanges(
}
}
return { range, base, baseRef, sha, context: ctx, files, truncated };
return { range, base, baseRef, mergeBase, sha, context: ctx, files, truncated };
}
/**
@@ -615,8 +621,10 @@ async function collectChanges(
*/
async function listCommits(dir, { limit = 30, file = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
const args = ["log", `-${n}`, `--format=${format}%x1e`];
const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
// --date-order: no parent before all its children, which the history's
// commit graph needs to draw a lane down to each parent.
const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`];
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file));
@@ -625,7 +633,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
for (const record of result.stdout.split("\x1e")) {
const line = record.replace(/^\n/, "");
if (!line.trim()) continue;
const [sha, short, subject, author, date, refs] = line.split("\x1f");
const [sha, short, subject, author, date, refs, parents] = line.split("\x1f");
commits.push({
sha,
short,
@@ -638,6 +646,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
.split(", ")
.map((r) => r.trim())
.filter(Boolean),
parents: (parents || "").split(" ").filter(Boolean),
});
}
return commits;
@@ -848,11 +857,45 @@ async function treeStatus(dir) {
};
}
/**
* Adds to one level what git does not list there — ignored files and folders
* (and an empty folder, which git never lists either) — marked `ignored`,
* read off the disk because `ls-files --ignored` cannot list INSIDE an
* ignored folder. Returns the folder names it added.
*/
async function addIgnored(dir, sub, dirNames, files) {
const added = new Set();
let dirents;
try {
const root = await fs.promises.realpath(dir);
const real = await fs.promises.realpath(path.join(root, sub));
const r = path.relative(root, real);
// A symlinked folder pointing out of the workspace lists nothing.
if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) return added;
dirents = await fs.promises.readdir(real, { withFileTypes: true });
} catch {
return added;
}
const prefix = sub ? `${sub}/` : "";
const listed = new Set(files.map((f) => f.name));
for (const d of dirents) {
if (d.name === ".git" || dirNames.has(d.name) || listed.has(d.name)) continue;
if (d.isDirectory()) {
dirNames.add(d.name);
added.add(d.name);
} else {
files.push({ name: d.name, path: `${prefix}${d.name}`, dir: false, ignored: true });
}
}
return added;
}
/** A folded level's entries, each carrying its status (see `treeStatus`). */
function treeEntries(prefix, dirNames, files, st) {
function treeEntries(prefix, dirNames, files, st, ignoredDirs = new Set()) {
return [
...[...dirNames].sort().map((name) => {
const p = `${prefix}${name}`;
if (ignoredDirs.has(name)) return { name, path: p, dir: true, ignored: true };
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
}),
...files
@@ -866,7 +909,8 @@ function treeEntries(prefix, dirNames, files, st) {
/**
* One directory level of the working tree, for the file explorer: tracked
* files plus untracked-but-not-ignored ones (`TREE_LS`), folded into
* files plus untracked-but-not-ignored ones (`TREE_LS`) and, marked
* `ignored`, what git ignores there (`addIgnored`), folded into
* directories at this level — a tree that lists a repo's every file at once is
* both slow to render and not what "expand a folder" means. A changed file
* carries its porcelain `status`, a folder holding one `changed: true`.
@@ -889,7 +933,8 @@ async function listTree(dir, { subdir = "" } = {}) {
if (slash === -1) files.push({ name: rest, path: p, dir: false });
else dirs.add(rest.slice(0, slash));
}
const entries = treeEntries(prefix, dirs, files, st);
const ignored = await addIgnored(dir, clean, dirs, files);
const entries = treeEntries(prefix, dirs, files, st, ignored);
return {
path: clean,
entries: entries.slice(0, MAX_PATHS),
@@ -986,7 +1031,7 @@ function onlyBlock(hunk, block, reverse) {
}
/**
* Every level of the explorer tree (tracked + untracked-not-ignored) from ONE `git ls-files`, keyed by directory
* Every level of the explorer tree (tracked + untracked-not-ignored, plus each level's ignored entries) from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
* per folder, which on a large repo is thousands of git processes.
@@ -1010,8 +1055,14 @@ async function listTreeAll(dir) {
}
const levels = {};
let truncated = cut;
// An ignored folder gets no level of its own: it stays shut (node_modules).
const ignored = new Map(
await Promise.all(
[...byDir].map(async ([d, l]) => [d, await addIgnored(dir, d, l.dirs, l.files)])
)
);
for (const [d, { dirs, files }] of byDir) {
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st, ignored.get(d));
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
+37 -19
View File
@@ -60,6 +60,23 @@ const ALLOWED_PERMISSION_MODES = new Set([
]);
// Pane commands that mean "idle shell prompt, safe to type a command into".
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
/**
* `sh` that waits until a client is attached to the pane — 2 s at most
* ({@link CLIENT_WAIT_MS}) — before what follows it runs. A session is born
* 80×24 and takes the browser's size only on attach (and `enableMouse`'s
* scrollbar narrows it before that). Anything drawn before then is wrong:
* a shell's prompt (issue #55), and an inline `claude`'s banner — tmux
* re-wraps the 80-column rules into two lines each on the resize, pushing the
* banner's first lines into history, and claude's redraw clears only the
* visible screen, so they stay above it as a duplicate. Not "the first
* SIGWINCH": the scrollbar fires one before any client is there. A tmux that
* cannot answer ends the wait at once, i.e. the old behaviour.
*/
const WAIT_FOR_CLIENT =
'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' +
" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;";
const CLIENT_WAIT_MS = 2000;
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const CODEX_APPROVALS = new Set(["on-request", "never"]);
// Codex has no `--effort` flag; reasoning effort is a config override.
@@ -452,11 +469,10 @@ function spawnRun(args) {
// journals and the router layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
// Claude Code inline even under `"tui": "fullscreen"`: on the alternate
// screen tmux keeps no history and draws no pane scrollbar, so the
// dashboard terminal had nothing to drag. The user's own terminals keep
// their setting — this reaches only panes AgentHub starts.
...(tool === "claude" ? { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" } : {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
...routerSpawn.env,
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
@@ -472,12 +488,18 @@ function spawnRun(args) {
} catch {
/* pane gone mid-paste — nothing to deliver to */
}
}, entry.prompt.delayMs);
}, entry.prompt.delayMs + CLIENT_WAIT_MS); // the CLI may start up to that late
if (timer.unref) timer.unref();
};
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv, env });
// argv rides as sh's positional parameters, never inside the script text.
tmux.newSession({
name: id,
cwd,
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv],
env,
});
record();
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
@@ -530,18 +552,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
};
// The session is born 80×24 and only takes the browser's size when the pane
// attaches. A shell started at once printed its prompt at 80 columns (a long
// cwd wrapped), and readline's redraw after the resize left the cursor on top
// of the prompt (issue #55). So wait until a client is attached — 2 s at
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
// resizes the pane before any client is there. A tmux that cannot answer
// ends the wait at once, i.e. the old behaviour.
const waitForSize =
"i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" +
' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l';
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
tmux.newSession({
name: id,
cwd,
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`],
env,
});
dashboardRuns?.recordRun({
id,
sessionId: null,
+243
View File
@@ -0,0 +1,243 @@
/**
* @file quota.js
* @description How much of each native AI tool's plan quota is spent, for the
* status bar's quota chips (`GET /api/pricing/quota`).
*
* - **Claude**: the logged-in account's `GET /api/oauth/usage` (`cc-usage.js`),
* with the access token the CLI holds RIGHT NOW. It is never refreshed here —
* a refresh rotates the CLI's credentials — so an expired token answers
* `ELIVEEXPIRED` until Claude Code next refreshes it itself. Every OTHER
* saved auth profile (`cc-profiles.js`) is asked with its own stored token
* while that token is valid and never refreshed (a refresh rotates the
* saved credentials); past expiry it shows its last figure, `asOf`. Cached
* `CLAUDE_TTL_MS` per account, so a poll from every open tab is one
* upstream call each. The live entry is `active: true`.
* - **Codex**: no network. Every `token_count` event Codex writes into its
* rollout journal carries the account's `rate_limits`; the newest one wins.
*
* Each tool also carries its signed-in `account` (an email) and `expiresAt`,
* when its stored access token runs out — Claude's `claudeAiOauth.expiresAt`,
* Codex's `auth.json` access-token `exp` (claims read, never verified).
*
* Only percentages, window lengths, reset/expiry times and the account's
* email leave this module — never a token.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getCodexHome } = require("./codex-home");
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
const { fetchUsage } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest");
const CLAUDE_TTL_MS = 5 * 60_000;
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
const EXPIRY_MARGIN_MS = 60_000;
const TAIL_BYTES = 512 * 1024;
const CODEX_FILES = 5;
const cache = new Map(); // "live" | "profile:<name>" → { at, value }
const lastGood = new Map(); // profile name → { windows, at }
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click). */
async function cached(key, force, fn) {
const hit = cache.get(key);
const age = hit ? Date.now() - hit.at : Infinity;
if (age < (force ? FORCE_MIN_MS : CLAUDE_TTL_MS)) return hit.value;
const value = await fn();
cache.set(key, { at: Date.now(), value });
return value;
}
function windowLabel(minutes) {
if (minutes === 300) return "5h";
if (minutes === 10080) return "wk";
if (minutes >= 40000 && minutes <= 45000) return "mo";
return minutes % 1440 === 0 ? `${minutes / 1440}d` : `${Math.round(minutes / 60)}h`;
}
const iso = (ms) => (Number(ms) ? new Date(Number(ms)).toISOString() : null);
const fresh = (ms) => Number(ms) > Date.now() + EXPIRY_MARGIN_MS;
function pct(w) {
return { usedPercent: w.utilization, resetsAt: w.resetsAt };
}
function claudeWindows(u) {
const windows = [];
if (u.fiveHour) windows.push({ label: "5h", ...pct(u.fiveHour) });
if (u.sevenDay) windows.push({ label: "wk", ...pct(u.sevenDay) });
return windows;
}
/** The account the CLI is logged into, with the token it holds right now. */
async function liveClaude() {
let who = {};
try {
const { oauth, account } = readActiveAuth();
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
who = {
account: account?.emailAddress || account?.displayName || null,
expiresAt: iso(oauth.expiresAt),
};
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
return { ...who, windows: claudeWindows(await fetchUsage(oauth.accessToken)) };
} catch (e) {
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
}
}
/**
* A saved profile that is not the live account, asked with its OWN stored
* token only while that token is still valid. Never refreshed here: a refresh
* rotates the profile's credentials on disk, so it happens only on the
* Profiles modal's Check quota click (`cc-usage.profileUsage`). Past expiry
* the last figure this process read is shown with its `asOf`.
*/
async function profileClaude(item) {
const who = { account: item.email || item.displayName || null, expiresAt: iso(item.expiresAt) };
let creds = null;
try {
creds = readProfile(item.name)?.credentials || null;
} catch {
/* a corrupt profile file reads as signed out */
}
if (creds?.accessToken && fresh(creds.expiresAt)) {
try {
const windows = claudeWindows(await fetchUsage(creds.accessToken));
lastGood.set(item.name, { windows, at: Date.now() });
return { ...who, windows };
} catch (e) {
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
}
}
const prev = lastGood.get(item.name);
if (!prev) return { ...who, windows: [], error: "EPROFILEEXPIRED" };
return { ...who, windows: prev.windows.map(expireReset), asOf: iso(prev.at) };
}
/** A window whose reset has passed is empty again. */
function expireReset(w) {
return w.resetsAt && Date.parse(w.resetsAt) < Date.now() ? { ...w, usedPercent: 0 } : w;
}
/** The live account first, then every other saved profile (by label). */
async function claudeQuotas(force) {
let items = [];
try {
items = listProfiles().items;
} catch {
/* no profiles dir */
}
const [live, ...others] = await Promise.all([
cached("live", force, liveClaude),
...items
.filter((it) => !it.active)
.map(async (it) => ({
id: "claude",
active: false,
label: it.label,
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
})),
]);
const mine = items.find((it) => it.active);
return [live && { id: "claude", active: true, label: mine?.label || null, ...live }, ...others];
}
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
function lastRateLimits(file) {
let text;
try {
const fd = fs.openSync(file.path, "r");
try {
const len = Math.min(file.size, TAIL_BYTES);
const buf = Buffer.alloc(len);
fs.readSync(fd, buf, 0, len, file.size - len);
text = buf.toString("utf8");
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
const lines = text.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
if (!lines[i].includes('"rate_limits"')) continue;
try {
const rl = JSON.parse(lines[i])?.payload?.rate_limits;
if (rl && (rl.primary || rl.secondary)) return { rl, mtime: file.mtime };
} catch {
/* a cut first line or a partial write */
}
}
return null;
}
function codexWindow(w, mtime) {
if (!w || typeof w.used_percent !== "number") return null;
const resetsMs = Number(w.resets_at)
? Number(w.resets_at) * 1000
: Number(w.resets_in_seconds)
? mtime + Number(w.resets_in_seconds) * 1000
: null;
// A window that has reset since Codex last wrote is empty again.
const used = resetsMs && resetsMs < Date.now() ? 0 : w.used_percent;
return {
label: windowLabel(Number(w.window_minutes)),
usedPercent: used,
resetsAt: resetsMs ? new Date(resetsMs).toISOString() : null,
};
}
/** A JWT's claims, unverified — only for showing who is signed in. */
function jwtClaims(token) {
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
}
/** Codex's ChatGPT login from `auth.json`: email + access-token expiry. */
function codexAccount() {
try {
const { tokens } = JSON.parse(fs.readFileSync(path.join(getCodexHome(), "auth.json"), "utf8"));
const { exp } = jwtClaims(tokens.access_token);
return {
account: jwtClaims(tokens.id_token).email || null,
expiresAt: exp ? new Date(exp * 1000).toISOString() : null,
};
} catch {
return {}; // API-key login, no file, or a token that is not a JWT
}
}
function codexQuota() {
const files = allRolloutFiles()
.sort((a, b) => b.mtime - a.mtime)
.slice(0, CODEX_FILES);
for (const f of files) {
const found = lastRateLimits(f);
if (!found) continue;
const windows = [found.rl.primary, found.rl.secondary]
.map((w) => codexWindow(w, found.mtime))
.filter(Boolean);
return {
id: "codex",
active: true,
...codexAccount(),
windows,
asOf: new Date(found.mtime).toISOString(),
};
}
return null;
}
async function quotas({ force = false } = {}) {
const tools = [...(await claudeQuotas(force)), codexQuota()].filter(Boolean);
return { tools, checkedAt: new Date().toISOString() };
}
function __resetCache() {
cache.clear();
lastGood.clear();
}
module.exports = { quotas, codexWindow, codexAccount, windowLabel, __resetCache };
+20 -2
View File
@@ -8,7 +8,8 @@
* theirs, not a choice made here:
*
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
* profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
* profile's eight `env` keys (plus `modelPicker` rows when the profile
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
* (0600). The command-line layer outranks the project-local and user
* settings (verified on 2.1.286), so two sessions of one workspace can run
* on two routers, and the key stays out of argv — only the path is there.
@@ -73,12 +74,29 @@ function writeSecret(file, body) {
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
}
/**
* `modelPicker` rows telling Claude Code which known model each of the router's
* own names behaves as — without one, a name like 9Router's `agent` draws an
* "isn't described by this version's model catalog" warning and a 200k
* compact window (issue #56). Only when the profile sets `behavesAs`: this
* layer's `modelPicker` replaces the user's own for the session. A bare
* `claude-*` id is skipped, since Claude Code already knows it.
*/
function modelPicker(profile) {
if (!profile.behavesAs) return {};
const names = new Set([profile.model, ...Object.values(profile.models || {})]);
const options = [...names]
.filter((m) => m && !m.startsWith("claude-"))
.map((model) => ({ model, behavesAs: profile.behavesAs }));
return options.length ? { modelPicker: { options } } : {};
}
function writeClaudeSettings(profile) {
const file = claudeSettingsPath(profile.name);
// Empty values are left out: a blank key in the command-line layer would
// blank the user's own setting, not leave it alone.
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
return file;
}
+4 -2
View File
@@ -99,13 +99,15 @@ function enableMouse(name) {
execImpl(["set-option", "-t", pane(name), "pane-scrollbars", "on"]);
// tmux's default (`fg=white,bg=black,width=1`) is a one-column track in
// ANSI black — the terminal theme's border colour, almost the background —
// so it reads as no scrollbar at all. Two columns, bright slider.
// so it reads as no scrollbar at all. Bright slider. ONE column: tmux 3.7c
// reserves `width` columns but paints only the first, so a second one is
// never cleared and shows stale cells — a ghost second scrollbar.
execImpl([
"set-option",
"-t",
pane(name),
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
"fg=brightwhite,bg=black,width=1,pad=0",
]);
} catch {
/* tmux < 3.7 */
+31
View File
@@ -674,6 +674,37 @@ const paths = {
},
},
},
"/api/lanes/{id}/git/commit-message": {
post: {
tags: ["Lanes"],
summary: "Suggest a commit message for the staged changes",
description:
"Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools \"\" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.",
operationId: "suggestLaneCommitMessage",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "session", in: "query", required: false, schema: { type: "string" } },
],
responses: {
200: {
description: "The suggested message",
content: {
"application/json": {
schema: {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
},
},
},
},
400: { description: "ENOSTAGED / ENOTOOL / ENOWORKTREE" },
403: { description: "Cross-origin request refused" },
404: { description: "No such workspace" },
502: { description: "EAI — the claude CLI failed" },
},
},
},
"/api/lanes/{id}/git/{action}": {
post: {
tags: ["Lanes"],
+39
View File
@@ -857,6 +857,45 @@ const paths = {
},
},
"/api/run/history/{id}": {
delete: {
tags: ["Run"],
summary: "Forget a closed Terminal tab",
description:
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
operationId: "runHistoryForget",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "The history row's id (RunHistoryItem.id).",
example: "agenthub-lane-5-2@2026-10-06T08:00:00.000Z",
},
],
responses: {
200: {
description: "Removed",
content: { "application/json": { example: { removed: "agenthub-lane-5-2" } } },
},
403: ebadOrigin403,
404: {
description: "No closed Terminal tab with that id (ENOTFOUND)",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
409: {
description: "That terminal is still open (ERUNLIVE)",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/run/cwds": {
get: {
tags: ["Run"],
+22
View File
@@ -15,6 +15,7 @@ const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
const { suggestCommitMessage } = require("../lib/commit-message");
const aiTools = require("../lib/ai-tools");
const {
listPipelines,
@@ -1061,6 +1062,27 @@ router.get("/:id/git/branches", async (req, res) => {
* rule about what each action may do lives in server/lib/git-ops.js, not here.
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
/**
* A commit message suggested by the user's `claude` CLI for what is STAGED
* (server/lib/commit-message.js). Not a git write — it only reads the index —
* but same-origin, because each call spends the user's Claude quota.
* Registered before `/:id/git/:action`, which would answer EBADACTION.
*/
router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({ message: await suggestCommitMessage(t.cwd) });
} catch (err) {
const status = ["ENOSTAGED", "ENOTOOL"].includes(err.code)
? 400
: err.code === "EAI"
? 502
: 500;
res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } });
}
});
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
+7
View File
@@ -6,6 +6,7 @@
const { Router } = require("express");
const { stmts, db } = require("../db");
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
const { quotas } = require("../lib/quota");
const {
WEB_SEARCH_PER_1K_SEARCHES,
CODE_EXEC_PER_HOUR,
@@ -508,6 +509,12 @@ router.get("/cost", (req, res) => {
res.json({ ...result, daily_costs });
});
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
router.get("/quota", async (req, res) => {
res.json(await quotas({ force: req.query.force === "1" }));
});
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
router.get("/cost/:sessionId", (req, res) => {
const rawOffset = parseInt(req.query.tz_offset, 10);
+17
View File
@@ -158,6 +158,23 @@ router.get("/history", (req, res) => {
});
});
/**
* Forget one closed Terminal tab (`tool = 'shell'`) from the history — the
* sidebar's remove. A live pane is refused; another tool's row is a 404.
*/
router.delete("/history/:id", (req, res) => {
if (runs.listRuns().some((h) => h.id === req.params.id)) {
return res.status(409).json({ error: { code: "ERUNLIVE", message: "terminal is still open" } });
}
const dr = require("../lib/dashboard-runs");
if (!dr.forgetShellRun(req.params.id)) {
return res
.status(404)
.json({ error: { code: "ENOTFOUND", message: "no closed terminal with that id" } });
}
res.json({ removed: req.params.id });
});
/**
* Suggest plausible working directories. Pulls from:
* - "dashboard": the dashboard server's cwd