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
This commit is contained in:
2026-10-06 15:38:25 +07:00
parent f0500dc592
commit daf5b7be92
65 changed files with 2414 additions and 587 deletions
+1 -1
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 - **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **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. **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", "name": "agenthub-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "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/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
@@ -410,6 +414,66 @@
"node": ">=6.9.0" "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": { "node_modules/@csstools/color-helpers": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1052,6 +1116,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2580,6 +2674,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -5702,6 +5802,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/style-to-js": {
"version": "1.1.21", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -6402,6 +6508,12 @@
"node": ">=0.10.0" "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": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "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" "test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
}, },
"dependencies": { "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/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
+7
View File
@@ -70,6 +70,7 @@ import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket"; import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications"; import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus"; import { eventBus } from "./lib/eventBus";
import { copyOnCtrlShiftC } from "./lib/keys";
import type { WSMessage } from "./lib/types"; import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first // 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); 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 ( return (
<> <>
<SplashScreen /> <SplashScreen />
+1 -1
View File
@@ -7,7 +7,7 @@
* codex-pets.net for more, and open the Sagi settings. The choice and the size * 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 * are viewer preferences (`prefs.ts`); the pets themselves live on the server
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared * (`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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
+1 -1
View File
@@ -315,7 +315,7 @@ export function Sagi() {
onClose={() => setMenu(false)} onClose={() => setMenu(false)}
onSettings={() => { onSettings={() => {
setMenu(false); setMenu(false);
navigate("/settings/pet"); navigate("/settings/appearance#pet");
}} }}
/> />
</SagiFlyout> </SagiFlyout>
@@ -159,7 +159,7 @@ describe("PetMenu", () => {
fireEvent.click(screen.getByText("Hide pet")); fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false); expect(sagiPrefs.getEnabled()).toBe(false);
// Says where to bring it back. // 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(); off();
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* @file usePets — the pet list and its actions, shared by the mascot's * @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 * `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 * 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 * `.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); onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]); }, [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 }) => const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${ `flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5" collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
} ${ } ${
isActive isActive
? "bg-accent/10 text-accent border border-accent/20" ? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`; }`;
return ( return (
@@ -451,7 +453,7 @@ export function Sidebar({
Chevron buttons appear at the edges when content is clipped, so the Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */} user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex"> <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 }) => { {NAV_KEYS.map(({ to, icon: Icon, key }) => {
const label = t(key); const label = t(key);
return ( return (
@@ -475,7 +477,7 @@ export function Sidebar({
onClick={onToggle} onClick={onToggle}
title={t("lanes:tree.title")} title={t("lanes:tree.title")}
aria-label={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" /> <FolderTree className="w-4 h-4" />
</button> </button>
@@ -528,8 +530,8 @@ export function Sidebar({
data-testid="sidebar-toggle" data-testid="sidebar-toggle"
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")} 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")} 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 ${ 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.5" : "h-9 w-9 flex-shrink-0" collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`} }`}
> >
{collapsed ? ( {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, * 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 * `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 * 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 * token total, and hovering it lists tokens and cost per model. At the right
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane. * 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> * @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 { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; 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 { api, type RunHandle } from "../lib/api";
import type { CostResult } from "../lib/types"; import type { CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format"; import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree"; import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000; 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 { function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0"); 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({ export function StatusBar({
wsConnected, wsConnected,
left, left,
@@ -213,6 +470,7 @@ export function StatusBar({
r={sessionCost} r={sessionCost}
/> />
)} )}
<QuotaChips />
</footer> </footer>
); );
} }
@@ -4,12 +4,13 @@
* state with the version (click opens the connection details), the running and * 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) * 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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi } from "vitest"; 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 { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -68,6 +69,42 @@ vi.mock("../../lib/api", () => ({
], ],
daily_costs: [], 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) => ({ sessionCost: vi.fn().mockImplementation(async (id: string) => ({
total_cost: id === "s-live" ? 4.5 : 7, total_cost: id === "s-live" ? 4.5 : 7,
breakdown: [], breakdown: [],
@@ -144,4 +181,52 @@ describe("StatusBar", () => {
}); });
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument(); 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);
});
}); });
+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"
/>
);
}
+297 -112
View File
@@ -7,6 +7,10 @@
* to open it * to open it
* review — every file's full diff, stacked, for reading a whole change-set * review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a * file — one file, as a diff or as its whole content (issue #35); a
* working file is always the editor (`DiffEditor`, diffed
* against HEAD / the branch's merge base, unified or side by
* side): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an * whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`) * image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff * history — recent commits; opening one shows that commit's diff
@@ -45,7 +49,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ChevronDown, ChevronDown,
@@ -57,6 +61,7 @@ import {
Code2, Code2,
Columns2, Columns2,
Eye, Eye,
EyeOff,
FileText, FileText,
FoldVertical, FoldVertical,
Folder, Folder,
@@ -77,12 +82,19 @@ import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed"; import { useArmed } from "../../hooks/useArmed";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView"; import {
LaneDiffView,
LaneFileView,
langFor,
type DiffLayout,
type LaneDiffChange,
} from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView"; import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
import type { import type {
LaneChangeFile, LaneChangeFile,
LaneChangeRange, LaneChangeRange,
@@ -136,6 +148,16 @@ function Delta({
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */ /** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i; const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited.
const DiffEditor = lazy(() => import("./DiffEditor"));
/** A file that opens straight in the editor: a working file with text — not a
* commit's, an image, a binary, a deleted file or a merge conflict (which
* opens in the conflict view). */
function editableFile(range: LaneChangeRange, path: string, f?: LaneChangeFile) {
if (range === "commit" || IMAGE_FILE.test(path)) return false;
return !f || (!f.binary && !f.conflicted && !f.status.includes("D"));
}
/** An image file, shown as itself; a broken one (deleted, too big) says so. */ /** An image file, shown as itself; a broken one (deleted, too big) says so. */
function ImageView({ src, path }: { src: string; path: string }) { function ImageView({ src, path }: { src: string; path: string }) {
@@ -198,10 +220,13 @@ function TreeNodes({
onOpenFile, onOpenFile,
onMenu, onMenu,
active, active,
kept,
}: { }: {
path: string; path: string;
depth: number; depth: number;
children: Record<string, LaneTreeEntry[]>; children: Record<string, LaneTreeEntry[]>;
/** Paths on the never-stage list: drawn orange, whatever their git status. */
kept?: Set<string>;
expanded: Set<string>; expanded: Set<string>;
onToggleDir: (path: string) => void; onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void; onOpenFile: (path: string) => void;
@@ -216,17 +241,25 @@ function TreeNodes({
<> <>
{entries.map((e) => { {entries.map((e) => {
const mark = e.status ? treeMark(e.status) : null; const mark = e.status ? treeMark(e.status) : null;
const isKept = !e.dir && !!kept?.has(e.path);
return ( return (
<li key={e.path}> <li key={e.path}>
<button <button
type="button" type="button"
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)} data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
data-tree-kept={isKept || undefined}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))} onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))} onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }} style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
aria-current={active === e.path ? "true" : undefined} aria-current={active === e.path ? "true" : undefined}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${ className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary" isKept
? "bg-kept/10 text-kept"
: mark
? mark.tone
: e.changed
? "text-status-warning"
: "text-fg-primary"
} ${active === e.path ? "!bg-surface-4" : ""}`} } ${active === e.path ? "!bg-surface-4" : ""}`}
> >
{e.dir ? ( {e.dir ? (
@@ -268,6 +301,7 @@ function TreeNodes({
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
onMenu={onMenu} onMenu={onMenu}
active={active} active={active}
kept={kept}
/> />
</ul> </ul>
)} )}
@@ -322,12 +356,17 @@ function FileActions({
armed, armed,
onAct, onAct,
onArm, onArm,
kept = false,
onKeep,
}: { }: {
f: LaneChangeFile; f: LaneChangeFile;
busy: boolean; busy: boolean;
armed: boolean; armed: boolean;
onAct: FileAct; onAct: FileAct;
onArm: (path: string) => void; onArm: (path: string) => void;
/** On the never-stage list: Stage all and a folder's stage skip it. */
kept?: boolean;
onKeep?: (path: string) => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
return ( return (
@@ -350,6 +389,14 @@ function FileActions({
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</IconButton> </IconButton>
) : null} ) : null}
{onKeep && (kept || f.unstaged || f.untracked) ? (
<IconButton
label={t(kept ? "changes.allowStage" : "changes.neverStage")}
onClick={() => onKeep(f.path)}
>
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
</IconButton>
) : null}
<IconButton <IconButton
label={armed ? t("changes.discardConfirm") : t("changes.discard")} label={armed ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy} disabled={busy}
@@ -425,8 +472,12 @@ function ChangeRow({
onMenu, onMenu,
name, name,
depth = 0, depth = 0,
kept = false,
onKeep,
}: { }: {
f: LaneChangeFile; f: LaneChangeFile;
kept?: boolean;
onKeep?: (path: string) => void;
/** In the folder tree: the file's own name, and how deep it sits. */ /** In the folder tree: the file's own name, and how deep it sits. */
name?: string; name?: string;
depth?: number; depth?: number;
@@ -461,8 +512,10 @@ function ChangeRow({
{f.untracked ? "A" : f.status.trim()[0] || "M"} {f.untracked ? "A" : f.status.trim()[0] || "M"}
</span> </span>
<span <span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary" className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
title={f.path} kept ? "text-fg-muted line-through" : "text-fg-primary"
}`}
title={kept ? `${f.path}\n${t("changes.keptHint")}` : f.path}
> >
{name ?? f.path} {name ?? f.path}
</span> </span>
@@ -475,7 +528,15 @@ function ChangeRow({
(f.conflicted ? ( (f.conflicted ? (
<ConflictActions f={f} busy={busy} onResolve={onResolve} /> <ConflictActions f={f} busy={busy} onResolve={onResolve} />
) : ( ) : (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} /> <FileActions
f={f}
busy={busy}
armed={armed}
onAct={onAct}
onArm={onArm}
kept={kept}
onKeep={onKeep}
/>
))} ))}
</li> </li>
); );
@@ -559,11 +620,56 @@ function ChangeTreeNodes({
const HISTORY_PAGE = 50; const HISTORY_PAGE = 50;
const HISTORY_MAX = 200; const HISTORY_MAX = 200;
// The history's commit graph: one fixed-height row per commit so its lanes
// join up row to row. Mid-tone hues that read on dark and light themes alike.
const GRAPH_ROW = 34;
const GRAPH_COL = 12;
const GRAPH_COLORS = [
"#3b82f6",
"#22c55e",
"#f59e0b",
"#a855f7",
"#ef4444",
"#14b8a6",
"#ec4899",
"#84cc16",
];
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
return (
<svg
aria-hidden
width={width * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
</svg>
);
}
export function LaneChangesPanel({ export function LaneChangesPanel({
laneId, laneId,
session, session,
onNewLane, onNewLane,
full = false, full = false,
wide = full,
onDirtyChange, onDirtyChange,
}: { }: {
laneId: number; laneId: number;
@@ -578,6 +684,8 @@ export function LaneChangesPanel({
/** The panel has the whole width: the file tree becomes a sidebar and a /** The panel has the whole width: the file tree becomes a sidebar and a
* whole file opens straight into the editor. */ * whole file opens straight into the editor. */
full?: boolean; full?: boolean;
/** Half the width or more: diffs and the editor open side by side. */
wide?: boolean;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below: // One read of the working copy, shared by the header and every write below:
@@ -607,7 +715,10 @@ export function LaneChangesPanel({
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null; const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
const [sha, setSha] = useState<string | null>(null); const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]); const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified"); // Side by side wherever there is room for two columns; a resize starts over
// from that default, a click on the toggle overrides it until then.
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false); const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file. // A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false); const [mdPreview, setMdPreview] = useState(false);
@@ -630,33 +741,17 @@ export function LaneChangesPanel({
// The whole-file editor's text while it is open; null = viewing. // The whole-file editor's text while it is open; null = viewing.
const [draft, setDraft] = useState<string | null>(null); const [draft, setDraft] = useState<string | null>(null);
const editWanted = useRef(false); const editWanted = useRef(false);
// Where the editor opens: the line clicked in the read view, and that view's // The text the editor diffs against: the file at HEAD (`work`) or at the
// scroll offset, so the text does not jump when the textarea replaces it. // branch's merge base; "" for a file that is new there.
const editAt = useRef<{ line: number; scrollTop: number } | null>(null); const [baseText, setBaseText] = useState<string | null>(null);
const editorRef = useRef<HTMLTextAreaElement>(null); const startEdit = () => {
const editing = draft !== null;
useEffect(() => {
const at = editAt.current;
const el = editorRef.current;
if (!editing || !at || !el) return;
editAt.current = null;
const offset = el.value
.split("\n")
.slice(0, at.line)
.reduce((n, l) => n + l.length + 1, 0);
el.setSelectionRange(offset, offset);
el.focus({ preventScroll: true });
el.scrollTop = at.scrollTop;
}, [editing]);
const startEdit = (line: number, scrollTop: number) => {
if (!text?.sha) return; if (!text?.sha) return;
editAt.current = { line, scrollTop };
setDraft(text.lines.join("\n")); setDraft(text.lines.join("\n"));
}; };
useEffect(() => { useEffect(() => {
if (!editWanted.current || !text) return; if (!editWanted.current || !text) return;
editWanted.current = false; editWanted.current = false;
if (text.sha) startEdit(0, 0); if (text.sha) startEdit();
// Asked for from the diff, refused once the file arrived: say why here, // Asked for from the diff, refused once the file arrived: say why here,
// not with an Edit button that silently does nothing. // not with an Edit button that silently does nothing.
else setError(t(noEditReason(text))); else setError(t(noEditReason(text)));
@@ -692,6 +787,32 @@ export function LaneChangesPanel({
if (!next.delete(key)) next.add(key); if (!next.delete(key)) next.add(key);
return next; return next;
}); });
// Files the user never wants staged (a local note, a scratch folder): Stage
// all and a folder's stage skip them; git is never told — nothing is written
// to the repo. A viewer preference per workspace, like the panel's size.
const neverStageKey = `agenthub.changes.neverStage.${laneId}`;
const [neverStage, setNeverStage] = useState<Set<string>>(new Set());
useEffect(() => {
try {
const saved = JSON.parse(window.localStorage.getItem(neverStageKey) || "[]");
setNeverStage(
new Set(Array.isArray(saved) ? saved.filter((p) => typeof p === "string") : [])
);
} catch {
setNeverStage(new Set());
}
}, [neverStageKey]);
const toggleNeverStage = (path: string) =>
setNeverStage((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
try {
window.localStorage.setItem(neverStageKey, JSON.stringify([...next]));
} catch {
/* kept for this visit only */
}
return next;
});
// Where opening a file came FROM: its mode and how far the list was scrolled. // Where opening a file came FROM: its mode and how far the list was scrolled.
// Reviewing a change-set is a walk down a long list, and landing back at the // Reviewing a change-set is a walk down a long list, and landing back at the
// top of it after every file is the whole cost of opening one. // top of it after every file is the whole cost of opening one.
@@ -846,10 +967,15 @@ export function LaneChangesPanel({
}; };
}, [mode, laneId, query, full]); }, [mode, laneId, query, full]);
// The open file's entry in the change list, and whether it is edited in
// place: a working file with text opens as the editor in both views.
const openEntry =
data?.available && openFile ? data.files.find((f) => f.path === openFile) : undefined;
const editable = mode === "file" && !!openFile && editableFile(range, openFile, openEntry);
// The whole-file read is a different endpoint from the diff, so it is its // The whole-file read is a different endpoint from the diff, so it is its
// own fetch — and only while the viewer is actually showing it. // own fetch — and only while the viewer is actually showing it.
useEffect(() => { useEffect(() => {
if (mode !== "file" || !whole || !openFile) return; if (mode !== "file" || !(whole || editable) || !openFile) return;
void api.lanes void api.lanes
.gitFile(target, openFile, range === "commit" && sha ? sha : "work") .gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then( .then(
@@ -868,17 +994,22 @@ export function LaneChangesPanel({
) )
) )
.catch((err) => setError(err instanceof Error ? err.message : String(err))); .catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]); }, [mode, whole, editable, openFile, laneId, range, sha]);
// The editor's original side, read beside the working file. A file that is
// not there (new since) diffs against nothing: every line is added.
const mergeBase = data?.available ? (data.mergeBase ?? null) : null;
useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
void api.lanes.gitFile(target, openFile, rev).then(
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
forLane(laneId, () => setBaseText(""))
);
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
useEffect(() => { useEffect(() => {
if (full && mode === "browse") setMode("overview"); if (full && mode === "browse") setMode("overview");
}, [full, mode]); }, [full, mode]);
// Going full width with a working file already open: straight to the editor.
useEffect(() => {
if (full && mode === "file" && range !== "commit") setWhole(true);
// Only on the switch itself — Whole file must still toggle afterwards.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [full]);
// A lane switch must not leave the previous lane's file open — its path // A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one. // almost certainly does not exist in the new one.
@@ -939,10 +1070,14 @@ export function LaneChangesPanel({
} }
setOpenFile(path); setOpenFile(path);
setContext(CONTEXT_STEPS[0]); setContext(CONTEXT_STEPS[0]);
// Full width edits by default: a working file opens as the editor, and // A file opens on its Changes. A working file is the editor folded to its
// Whole file toggles back to its diff. A commit is history, so a diff. // changes (each block with a Revert); Whole file unfolds it. Anything else
setWhole(full && range !== "commit"); // — a commit, a binary, a deleted file — is the read-only diff.
const f = data?.available ? data.files.find((x) => x.path === path) : undefined;
editWanted.current = editableFile(range, path, f);
setWhole(false);
setText(null); setText(null);
setBaseText(null);
setDraft(null); setDraft(null);
setRawEdit(false); setRawEdit(false);
setMode("file"); setMode("file");
@@ -978,7 +1113,8 @@ export function LaneChangesPanel({
if (r.available) { if (r.available) {
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha }); setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
} }
setDraft(null); // Saved, still editing: the editor stays on the text just written.
setDraft(r.available ? r.lines.join("\n") : null);
refreshGit(); refreshGit();
await load(); await load();
} catch (err) { } catch (err) {
@@ -1105,6 +1241,9 @@ export function LaneChangesPanel({
{t("changes.unstage")} {t("changes.unstage")}
</MenuItem> </MenuItem>
)} )}
<MenuItem onSelect={pick(fileCtx.close, () => toggleNeverStage(menuFile.path))}>
{t(neverStage.has(menuFile.path) ? "changes.allowStage" : "changes.neverStage")}
</MenuItem>
<MenuItem <MenuItem
danger danger
disabled={busy} disabled={busy}
@@ -1303,7 +1442,9 @@ export function LaneChangesPanel({
// What "Stage all" sends: every listed file with a change the index lacks. // What "Stage all" sends: every listed file with a change the index lacks.
// Never a conflicted one — staging it would mark it resolved. // Never a conflicted one — staging it would mark it resolved.
const toStage = const toStage =
range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : []; range === "work"
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
: [];
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined; const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
// The armed path left the list (committed, reverted, lane switched): disarm, // The armed path left the list (committed, reverted, lane switched): disarm,
@@ -1314,11 +1455,17 @@ export function LaneChangesPanel({
} }
}, [data, files, confirmDiscard]); }, [data, files, confirmDiscard]);
// A tree or quick-open pick opens the whole file — and, with an edit open, // A tree or quick-open pick opens a changed file on its Changes like any
// would drop it, so a dirty draft asks first. // other, and an unchanged one — no diff to show — as the whole file, in the
// editor. With an edit open it would drop it, so a dirty draft asks first.
const openWhole = async (p: string) => { const openWhole = async (p: string) => {
if (!(await confirmDrop())) return; if (!(await confirmDrop())) return;
showNow(p); showNow(p);
// The overview's list, or — with one file open, the list is that file —
// the working-copy facts' (capped) list of changed paths.
const changed = (git?.available ? git.files : []).map((f) => f.path);
if ([...files.map((f) => f.path), ...changed].includes(p)) return;
editWanted.current = editableFile(range, p);
setWhole(true); setWhole(true);
}; };
@@ -1384,6 +1531,7 @@ export function LaneChangesPanel({
onOpenFile={(p) => void openWhole(p)} onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open} onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null} active={full && mode === "file" ? openFile : null}
kept={neverStage}
/> />
</ul> </ul>
)} )}
@@ -1531,7 +1679,7 @@ export function LaneChangesPanel({
file to get the tight view back. */} file to get the tight view back. */}
<IconButton <IconButton
label={t("changes.collapseContext")} label={t("changes.collapseContext")}
disabled={whole || context <= CONTEXT_STEPS[0]} disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
onClick={() => onClick={() =>
setContext( setContext(
[...CONTEXT_STEPS].reverse().find((step) => step < context) ?? [...CONTEXT_STEPS].reverse().find((step) => step < context) ??
@@ -1543,7 +1691,7 @@ export function LaneChangesPanel({
</IconButton> </IconButton>
<IconButton <IconButton
label={t("changes.expandContext")} label={t("changes.expandContext")}
disabled={whole || context >= LAST_CONTEXT} disabled={whole || draft !== null || context >= LAST_CONTEXT}
onClick={() => onClick={() =>
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context) setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
} }
@@ -1556,17 +1704,35 @@ export function LaneChangesPanel({
)} )}
{mode === "file" && ( {mode === "file" && (
<button <div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
type="button" {([false, true] as const).map((w) => (
aria-pressed={whole} <button
disabled={dirty} key={String(w)}
onClick={() => setWhole(!whole)} type="button"
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${ aria-pressed={whole === w}
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2" onClick={() => {
}`} if (whole === w) return;
> // Both views of a working file are the same editor: the
{t("changes.wholeFile")} // draft goes along, unsaved or not.
</button> if (editable && draft !== null) return setWhole(w);
// Back to Changes drops an open edit, so unsaved text asks first.
void confirmDrop().then((ok) => {
if (!ok) return;
if (w) editWanted.current = editableFile(range, openFile ?? "", current);
else setDraft(null);
setWhole(w);
});
}}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
whole === w
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(w ? "changes.wholeFile" : "changes.changesView")}
</button>
))}
</div>
)} )}
</div> </div>
)} )}
@@ -1622,30 +1788,38 @@ export function LaneChangesPanel({
}`} }`}
> >
{mode === "history" ? ( {mode === "history" ? (
<ul data-testid="lane-changes-history" className="space-y-0.5"> <ul data-testid="lane-changes-history">
{(commits ?? []).map((c) => ( {(() => {
<li key={c.sha}> const list = commits ?? [];
<button const graph = commitGraph(list);
type="button" return list.map((c, i) => (
onClick={() => viewCommit(c.sha)} <li key={c.sha}>
onContextMenu={(e) => commitCtx.open(e, c)} <button
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2" type="button"
> onClick={() => viewCommit(c.sha)}
<span className="flex items-baseline gap-1.5"> onContextMenu={(e) => commitCtx.open(e, c)}
<span className="shrink-0 font-mono text-[10px] text-fg-muted"> style={{ height: GRAPH_ROW }}
{c.short} className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<GraphCell row={graph.rows[i]!} width={graph.width} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</span> </span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"> </button>
{c.subject} </li>
</span> ));
</span> })()}
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</button>
</li>
))}
{commits === null && ( {commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li> <li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)} )}
@@ -1831,6 +2005,8 @@ export function LaneChangesPanel({
onArm={setConfirmDiscard} onArm={setConfirmDiscard}
onResolve={resolve} onResolve={resolve}
onMenu={fileCtx.open} onMenu={fileCtx.open}
kept={neverStage.has(f.path)}
onKeep={toggleNeverStage}
/> />
)} )}
folderAction={ folderAction={
@@ -1846,7 +2022,11 @@ export function LaneChangesPanel({
onClick={() => onClick={() =>
void act( void act(
g.key === "staged" ? "unstage" : "stage", g.key === "staged" ? "unstage" : "stage",
rows.map((f) => f.path) rows
.filter(
(f) => g.key === "staged" || !neverStage.has(f.path)
)
.map((f) => f.path)
) )
} }
> >
@@ -1898,7 +2078,7 @@ export function LaneChangesPanel({
binary={current.binary} binary={current.binary}
/> />
)} )}
{whole && text && draft === null && isMarkdown(openFile) && ( {whole && text && !dirty && isMarkdown(openFile) && (
<button <button
type="button" type="button"
data-testid="lane-file-preview" data-testid="lane-file-preview"
@@ -1911,9 +2091,8 @@ export function LaneChangesPanel({
</button> </button>
)} )}
{/* Only the working file is editable: a commit is history. */} {/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text; {/* A working file opens in the editor; the read-only diff
only the diff, which has no whole text to click, keeps (Whole file off) keeps an Edit button back into it. */}
an Edit button. */}
{range !== "commit" && {range !== "commit" &&
!openImage && !openImage &&
(draft === null && whole && text && !text.sha ? ( (draft === null && whole && text && !text.sha ? (
@@ -1923,14 +2102,7 @@ export function LaneChangesPanel({
> >
{t(noEditReason(text))} {t(noEditReason(text))}
</span> </span>
) : draft === null && whole ? ( ) : draft === null && whole ? null : draft === null ? (
text &&
!mdPreview && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</span>
)
) : draft === null ? (
<button <button
type="button" type="button"
data-testid="lane-file-edit" data-testid="lane-file-edit"
@@ -1959,8 +2131,13 @@ export function LaneChangesPanel({
</button> </button>
<button <button
type="button" type="button"
disabled={busy} data-testid="lane-file-cancel"
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))} disabled={busy || !dirty}
onClick={() =>
void confirmDrop().then(
(ok) => ok && setDraft(text ? text.lines.join("\n") : null)
)
}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40" className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
> >
{t("changes.cancelEdit")} {t("changes.cancelEdit")}
@@ -1977,23 +2154,32 @@ export function LaneChangesPanel({
range === "commit" && sha ? sha : "work" range === "commit" && sha ? sha : "work"
)} )}
/> />
) : whole && draft !== null ? ( ) : !mdPreview &&
<textarea (whole || editable) &&
ref={editorRef} (draft !== null || (editable && text === null)) ? (
data-testid="lane-file-editor" // The editor, once both texts are in: not the diff first.
aria-label={t("changes.editorLabel", { path: openFile })} draft === null || (baseText === null && range !== "commit") ? (
value={draft} <p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
onChange={(e) => setDraft(e.target.value)} ) : (
onKeyDown={(e) => { <Suspense
if ((e.ctrlKey || e.metaKey) && e.key === "s") { fallback={<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>}
e.preventDefault(); >
if (!busy) void saveDraft(); <DiffEditor
} key={`${openFile}:${layout}:${whole}`}
}} layout={layout}
spellCheck={false} collapse={!whole}
wrap="off" revertLabel={t("changes.revertBlock")}
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none" value={draft}
/> original={baseText ?? draft}
lang={langFor(openFile ?? "")}
label={t("changes.editorLabel", { path: openFile })}
onChange={setDraft}
onSave={() => {
if (!busy) void saveDraft();
}}
/>
</Suspense>
)
) : whole ? ( ) : whole ? (
text && mdPreview ? ( text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4"> <div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
@@ -2005,7 +2191,6 @@ export function LaneChangesPanel({
lines={text.lines} lines={text.lines}
truncated={text.truncated} truncated={text.truncated}
fill fill
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
/> />
) : ( ) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p> <p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
+24 -44
View File
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
export const ROW_PX = 16; export const ROW_PX = 16;
const OVERSCAN = 20; const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX }; 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` * 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. */ /** 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() || ""; const ext = filePath.split(".").pop() || "";
return canonicalLang(ext === filePath ? "plain" : ext); return canonicalLang(ext === filePath ? "plain" : ext);
} }
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
return ( return (
<> <>
<Code text={text.slice(0, from)} lang={lang} /> <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} /> <Code text={text.slice(from, to)} lang={lang} />
</span> </span>
<Code text={text.slice(to)} lang={lang} /> <Code text={text.slice(to)} lang={lang} />
@@ -209,9 +210,10 @@ function emphasis(
while (start < max && del[start] === add[start]) start += 1; while (start < max && del[start] === add[start]) start += 1;
let end = 0; let end = 0;
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1; 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 // No letter or digit in common at either edge (blanks, a `;`, a `}` do not
// highlighting all of it adds noise on top of the row tint. // count): the whole line is the change — a removed line and an added one,
if (start === 0 && end === 0) return null; // 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 { return {
del: [start, del.length - end], del: [start, del.length - end],
add: [start, add.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. /** 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({ function Row({
tint, tint,
style, style,
@@ -310,7 +313,7 @@ function Row({
return ( return (
<div <div
className={`flex w-max min-w-full ${tint}`} className={`flex w-max min-w-full ${tint}`}
style={style} style={style ?? MIN_ROW}
data-diff-item={style ? "" : undefined} data-diff-item={style ? "" : undefined}
data-change-index={anchor} 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 /** 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). */ * separate from context at a glance on every theme (the /10 fill alone did not).
function rowTint(type: LaneDiffLine["type"] | null) { * 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") if (type === "add")
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]"; 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))]"; if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
return ""; 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 === "add") return "font-bold text-status-success";
if (type === "del") return "font-bold text-status-danger"; if (type === "del") return "font-bold text-status-danger";
return "text-fg-muted"; return "text-fg-muted";
@@ -485,11 +492,11 @@ function UnifiedLine({
}) { }) {
const anchor = useAnchor(start); const anchor = useAnchor(start);
return ( 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.oldLine} />
<Gutter n={line.newLine} commentable /> <Gutter n={line.newLine} commentable />
<span className="whitespace-pre px-2"> <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" ? "-" : " "} {line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span> </span>
<Code text={line.text} lang={lang} emph={emph} /> <Code text={line.text} lang={lang} emph={emph} />
@@ -558,7 +565,7 @@ function SplitLine({
const line = side === "left" ? row.left : row.right; const line = side === "left" ? row.left : row.right;
const anchor = useAnchor(start); const anchor = useAnchor(start);
return ( return (
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}> <Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
<Gutter <Gutter
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
commentable={side === "right"} commentable={side === "right"}
@@ -941,16 +948,12 @@ export function LaneFileView({
lines, lines,
truncated, truncated,
fill = false, fill = false,
onEditAt,
}: { }: {
path: string; path: string;
lines: string[]; lines: string[];
truncated?: boolean; truncated?: boolean;
/** Take the parent's height instead of the 60vh cap. */ /** Take the parent's height instead of the 60vh cap. */
fill?: boolean; 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 { t } = useTranslation(["lanes"]);
const lang = langFor(path); const lang = langFor(path);
@@ -959,12 +962,9 @@ export function LaneFileView({
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]); const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => ( const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}> <Row key={i} tint="" style={style}>
{/* `contents`: a line marker for the click handler, not a layout box. */} <Gutter n={i + 1} />
<span data-ln={i} className="contents"> <span className="whitespace-pre px-2">
<Gutter n={i + 1} /> <Code text={text} lang={lang} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</span> </span>
</Row> </Row>
); );
@@ -972,27 +972,7 @@ export function LaneFileView({
<div <div
data-testid="lane-file-view" data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)} onScroll={(e) => win.onScroll(e.currentTarget)}
tabIndex={onEditAt ? 0 : undefined} className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
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" : ""
}`}
> >
{win.on ? ( {win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}> <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, * @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 * head, subject) and the git action bar under it (`LaneGitBar`); plus the
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes * uncommitted counts drawn vertical on the CLOSED Changes rail
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel — * (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
* the open panel lists those files itself, so its header chip opens nothing. * 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 — * 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. * 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({ function LaneGitFilesPanel({
files, files,
hidden, hidden,
onShowAll,
anchor, anchor,
side = "below", side = "below",
onPointerEnter, onPointerEnter,
@@ -109,6 +112,8 @@ function LaneGitFilesPanel({
}: { }: {
files: LaneGitFile[]; files: LaneGitFile[];
hidden: number; hidden: number;
/** Opens the Changes panel, which lists every file the cap left out. */
onShowAll?: () => void;
/** The counts button this panel hangs off. */ /** The counts button this panel hangs off. */
anchor: HTMLElement | null; anchor: HTMLElement | null;
/** `left` beside a chip on the right-edge rail, where below would cover it. */ /** `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. // partial one. `hidden` says how many are missing.
const added = files.reduce((n, f) => n + (f.added ?? 0), 0); const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0); const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
const total = files.length + hidden;
return createPortal( return createPortal(
<div <div
ref={panelRef} ref={panelRef}
data-testid="lane-git-files" data-testid="lane-git-files"
role="group" 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 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. // the panel answers the same enter/leave pair the trigger does.
onMouseEnter={onPointerEnter} 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" 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]"> <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="ml-auto shrink-0 font-mono tabular-nums">
<span className="text-status-success">+{added}</span>{" "} <span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span> <span className="text-status-danger">−{deleted}</span>
@@ -206,30 +212,40 @@ function LaneGitFilesPanel({
); );
})} })}
</ul> </ul>
{hidden > 0 && ( {hidden > 0 &&
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted"> (onShowAll ? (
{t("git.filesMore", { count: hidden })} <button
</p> 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>, </div>,
document.body document.body
); );
} }
/** /**
* The uncommitted counts as a chip. With `details` it opens the per-file panel * The uncommitted counts as a chip that opens the per-file panel on hover,
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing * focus or click — the closed Changes rail, `vertical`, where nothing else
* else lists the files. In the open panel the list below already does, so the * lists the files.
* header shows the chip alone.
*/ */
export function LaneGitCounts({ export function LaneGitCounts({
git, git,
vertical = false, vertical = false,
details = true, onShowAll,
}: { }: {
git: Extract<LaneGitFacts, { available: true }>; git: Extract<LaneGitFacts, { available: true }>;
vertical?: boolean; vertical?: boolean;
details?: boolean; /** See `LaneGitFilesPanel`. */
onShowAll?: () => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false); 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 ${ 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" 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 ( return (
<> <>
<button <button
@@ -288,6 +297,13 @@ export function LaneGitCounts({
<LaneGitFilesPanel <LaneGitFilesPanel
files={git.files} files={git.files}
hidden={git.dirty + git.untracked - git.files.length} hidden={git.dirty + git.untracked - git.files.length}
onShowAll={
onShowAll &&
(() => {
setFilesOpen(false);
onShowAll();
})
}
anchor={anchor} anchor={anchor}
side={vertical ? "left" : "below"} side={vertical ? "left" : "below"}
onPointerEnter={openFiles} onPointerEnter={openFiles}
@@ -300,9 +316,18 @@ export function LaneGitCounts({
/** The counts on the closed Changes rail: its own facts read, polled only /** 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). */ * 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); 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({ export function LaneGitHeader({
@@ -339,7 +364,6 @@ export function LaneGitHeader({
<span className="shrink-0 text-fg-muted">{git.head}</span> <span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span> <span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div> </div>
<LaneGitCounts git={git} details={false} />
</> </>
); );
return ( 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 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="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> </button>
)} )}
{!renaming && ( {!renaming && (
@@ -437,7 +437,7 @@ export function WorkspaceSessionList({
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) { if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
return ( 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} {failure}
{confirmArchive} {confirmArchive}
</> </>
@@ -445,7 +445,11 @@ export function WorkspaceSessionList({
} }
return ( 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) => ( {fresh.map((r) => (
<li key={r.id}> <li key={r.id}>
<button <button
@@ -460,7 +464,7 @@ export function WorkspaceSessionList({
} }
onContextMenu={(e) => ctx.open(e, r)} onContextMenu={(e) => ctx.open(e, r)}
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`} 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 r.id === selectedRunId
? "bg-accent/10 text-fg-primary" ? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -472,7 +476,7 @@ export function WorkspaceSessionList({
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")} {r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span> </span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted"> <span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt) : null} {r.startedAt ? timeAgo(r.startedAt, true) : null}
</span> </span>
{r.tool === "shell" ? ( {r.tool === "shell" ? (
<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" />
@@ -490,7 +494,7 @@ export function WorkspaceSessionList({
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId; const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) { if (renaming === s.id) {
return ( return (
<li key={s.id} className="py-0.5 pl-7 pr-2"> <li key={s.id} className="py-0.5 pr-2">
<input <input
data-testid={`workspace-session-rename-${s.id}`} data-testid={`workspace-session-rename-${s.id}`}
aria-label={t("tree.renameSession")} aria-label={t("tree.renameSession")}
@@ -544,7 +548,7 @@ export function WorkspaceSessionList({
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}` ? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: "" : ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`} }\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 selected
? "bg-accent/10 text-fg-primary" ? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -564,12 +568,12 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted" className="h-3 w-3 shrink-0 text-fg-muted"
/> />
)} )}
<span className="ml-auto shrink-0 text-[11px] text-fg-muted"> <span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)} {timeAgo(s.last_activity ?? s.started_at, true)}
</span> </span>
<AiToolIcon <AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }} tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="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>
<button <button
@@ -595,7 +599,7 @@ export function WorkspaceSessionList({
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)} onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)} onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")} 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 <span
aria-hidden aria-hidden
@@ -604,7 +608,7 @@ export function WorkspaceSessionList({
<span className="sr-only">{t("tree.state.idle")}</span> <span className="sr-only">{t("tree.state.idle")}</span>
<span className="truncate">{t("tabs.terminal")}</span> <span className="truncate">{t("tabs.terminal")}</span>
<span className="ml-auto shrink-0 text-[11px]"> <span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)} {timeAgo(r.ended_at ?? r.started_at, true)}
</span> </span>
<SquareTerminal <SquareTerminal
aria-hidden aria-hidden
@@ -629,7 +633,7 @@ export function WorkspaceSessionList({
type="button" type="button"
data-testid={`workspace-sessions-more-${lane.id}`} data-testid={`workspace-sessions-more-${lane.id}`}
onClick={() => own && setLimit(own.total)} 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 })} {t("tree.seeAll", { count: more })}
</button> </button>
@@ -0,0 +1,90 @@
/**
* @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();
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"]);
});
});
@@ -19,6 +19,36 @@ import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types"; import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here. // 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", () => ({ vi.mock("../../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true), confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true), confirmDialog: vi.fn(async () => true),
@@ -90,6 +120,11 @@ 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. */ /** The overview row's discard button for one path. */
function discardOf(path: string) { function discardOf(path: string) {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", { return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
@@ -98,6 +133,7 @@ function discardOf(path: string) {
} }
beforeEach(() => { beforeEach(() => {
window.localStorage.removeItem("agenthub.changes.neverStage.7");
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false }); vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset(); vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes()); vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -113,6 +149,7 @@ beforeEach(() => {
author: "t", author: "t",
date: "2026-09-22T01:00:00Z", date: "2026-09-22T01:00:00Z",
refs: ["HEAD -> feat/thing"], refs: ["HEAD -> feat/thing"],
parents: [],
}, },
], ],
}); });
@@ -192,6 +229,7 @@ describe("LaneChangesPanel", () => {
context: 3, context: 3,
}) })
); );
await toDiff(user);
// The highlighter splits a line into one span per token, so assert on the // 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. // rendered text of the whole view rather than on a single node.
const view = await screen.findByTestId("lane-diff-view"); const view = await screen.findByTestId("lane-diff-view");
@@ -247,6 +285,45 @@ 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 () => { it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
@@ -393,12 +470,12 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar"); const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md")); 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")); await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); 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")); 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 () => { it("shows an image file as the picture, with no edit control", async () => {
@@ -427,7 +504,7 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull(); 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({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
path: "", path: "",
@@ -452,29 +529,29 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1); expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts")); await user.click(await within(sidebar).findByText("main.ts"));
// Opening a file only shows it: no editor until the text itself is clicked. // Opening a file is editing it, diffed against its HEAD text.
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);
const editor = await screen.findByTestId("lane-file-editor"); const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;"); expect(editor).toHaveValue("const whole = 1;");
expect(editor).toHaveFocus(); 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-save")).toBeDisabled();
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
await user.type(editor, "x"); await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled(); expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel asks before dropping an edit: "keep" keeps it… // Cancel asks before dropping an edit: "keep" keeps it…
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false); 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(confirmDiscard).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x"); expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" drops it and goes back to reading. // …and "discard" reverts it, still editing.
await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByTestId("lane-file-cancel"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;"); expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); 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({ vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true, available: true,
path: "server/lib/git-ops.js", path: "server/lib/git-ops.js",
@@ -488,31 +565,40 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview"); const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js")); await user.click(within(list).getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a"); await screen.findByRole("button", { name: "Changes", pressed: true });
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); // 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({ vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true, available: true,
path: "server/lib/git-ops.js", path: "server/lib/git-ops.js",
rev: "work", rev: "work",
binary: false, binary: false,
truncated: false, truncated: false,
lines: ["copy me"], lines: ["a"],
sha: "b".repeat(40), sha: "b".repeat(40),
}); });
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview"); const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js")); await user.click(within(list).getByTitle("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view"); expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
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();
}); });
it("needs two clicks to discard, and only the second one reaches the server", async () => { it("needs two clicks to discard, and only the second one reaches the server", async () => {
@@ -640,6 +726,7 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" })); 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 user.click(await screen.findByText("feat: the commit"));
await waitFor(() => await waitFor(() =>
@@ -785,7 +872,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" })); await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main"); 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.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
const editor = await screen.findByTestId("lane-file-editor"); const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;"); expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor); await user.clear(editor);
@@ -798,9 +884,9 @@ describe("LaneChangesPanel", () => {
"const whole = 2;", "const whole = 2;",
"b".repeat(40) "b".repeat(40)
); );
// Back to reading, showing what was saved. // Still editing, on what was saved: nothing left to save.
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;"); await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); 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 () => { it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
@@ -844,7 +930,6 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "Files" })); await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main"); 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.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.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save")); await user.click(screen.getByTestId("lane-file-save"));
@@ -885,6 +970,7 @@ describe("LaneChangesPanel", () => {
}) })
); );
await user.click(screen.getByTitle("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"); expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" })); await user.click(screen.getByRole("button", { name: "Side-by-side" }));
@@ -896,6 +982,7 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTitle("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" }); await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an // Collapse is dead at the tightest step, and live once expanded — an
@@ -926,7 +1013,7 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview"); await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTitle("server/lib/git-ops.js")); await user.click(screen.getByTitle("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/); await toDiff(user);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" })); await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
await waitFor(() => await waitFor(() =>
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto"); 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", () => { 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 // Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a // 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", () => { describe("LaneDiffView — row styling", () => {
it("tints add/del rows with their status fill and left bar; context stays plain", () => { // A line added or removed whole: nothing in common with its pair.
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />); const WHOLE = fileWith([
const add = rowOf(container, "x = 2"); { type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
const del = rowOf(container, "x = 1"); { 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()"); const ctx = rowOf(container, "keep()");
expect(add.className).toContain("bg-status-success/20"); expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success"); expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20"); expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger"); 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", () => { 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) => const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!; row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "x = 2")); const plus = marker(rowOf(container, "xyz"));
const minus = marker(rowOf(container, "x = 1")); const minus = marker(rowOf(container, "abc"));
const ctx = marker(rowOf(container, "keep()")); const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+"); expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold"); 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", () => { it("emphasises only the changed span of a paired del/add", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />); 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 (e) => e.textContent
); );
expect(emph).toEqual(["1", "2"]); expect(emph).toEqual(["1", "2"]);
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
layout="unified" 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", () => { it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
behind: null, 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); vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />); render(<Harness laneId={1} />);
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
expect(block.textContent).toContain("feat/rename-metric"); expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a"); expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form"); 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 () => { it("drops the previous lane's facts the moment the lane changes", async () => {
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
render(<LaneGitRailCounts laneId={1} />); render(<LaneGitRailCounts laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts")); 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"); 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); vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />); render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts"); await screen.findByTestId("lane-git");
fireEvent.mouseEnter(counts); expect(screen.queryByTestId("lane-git-counts")).toBeNull();
fireEvent.focus(counts);
expect(screen.queryByTestId("lane-git-files")).toBeNull();
}); });
it("draws the rail's counts vertical", async () => { it("draws the rail's counts vertical", async () => {
@@ -1,8 +1,10 @@
/** /**
* @file AppearanceSettings.tsx * @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and * @description Settings → Appearance (`/settings/appearance`): the interface language, the
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in * Dark / Light / System mode switch and one named-theme picker per mode, and the
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its * 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 * 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. * 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. * **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
@@ -11,7 +13,8 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react"; 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 { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
@@ -61,7 +64,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
} }
export function AppearanceSettings() { export function AppearanceSettings() {
const { t } = useTranslation("settings"); const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme(); const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const reset = async () => { const reset = async () => {
@@ -78,67 +81,99 @@ export function AppearanceSettings() {
}; };
return ( return (
<div className="card p-5 space-y-5"> <div className="space-y-6">
<SettingsRow <SettingsSection
label={t("appearance.mode")} id="language"
control={ title={t("language.title")}
<SettingsSegmented description={t("language.description")}
ariaLabel={t("appearance.mode")} >
value={mode} <SettingsRow
onChange={setMode} label={t("language.label")}
options={MODES.map(({ mode: m, Icon }) => ({ control={
value: m, <SettingsSegmented
label: ( ariaLabel={t("language.label")}
<> value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
<Icon className="w-3.5 h-3.5" /> onChange={(v) => void i18n.changeLanguage(v)}
{t(`appearance.modes.${m}`)} 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) => ( <section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<div key={group}> <h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<p className="text-xs font-medium text-fg-secondary mb-2"> <SettingsRow
{t(`appearance.${group}Themes`)} label={t("appearance.mode")}
{theme === group && ( control={
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span> <SettingsSegmented
)} ariaLabel={t("appearance.mode")}
</p> value={mode}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> onChange={setMode}
{THEMES[group].map((name) => { options={MODES.map(({ mode: m, Icon }) => ({
const selected = themeFor[group] === name; value: m,
const label = THEME_LABELS[name] ?? t("appearance.default"); label: (
return ( <>
<button <Icon className="w-3.5 h-3.5" />
key={name} {t(`appearance.modes.${m}`)}
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" {(["dark", "light"] as const).map((group) => (
}`} <div key={group}>
> <p className="text-xs font-medium text-fg-secondary mb-2">
<Swatch mode={group} name={name} /> {t(`appearance.${group}Themes`)}
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span> {theme === group && (
</button> <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> ))}
))}
<div className="flex justify-end border-t border-border pt-3"> <div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs"> <button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")} {t("appearance.reset")}
</button> </button>
</div> </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> </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 * 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 * 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 * install on the first pick, never bundled), and your own (deletable) — plus
@@ -1,14 +1,17 @@
/** /**
* @file AppearanceSettings.test.tsx * @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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import i18n from "../../../i18n";
import { AppearanceSettings } from "../AppearanceSettings"; import { AppearanceSettings } from "../AppearanceSettings";
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
const root = document.documentElement; const root = document.documentElement;
describe("AppearanceSettings", () => { describe("AppearanceSettings", () => {
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
root.removeAttribute("data-mode"); 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", () => { it("renders every theme of both modes, defaults selected", () => {
render(<AppearanceSettings />); render(<AppearanceSettings />);
const pressed = screen const pressed = screen
+5 -1
View File
@@ -68,7 +68,11 @@
"justNow": "just now", "justNow": "just now",
"mAgo": "{{count}}m ago", "mAgo": "{{count}}m ago",
"hAgo": "{{count}}h 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": { "pagination": {
"previous": "Previous", "previous": "Previous",
+5 -1
View File
@@ -60,6 +60,9 @@
"changes.commitPlaceholder": "Commit message", "changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged", "changes.commitStaged": "Commit {{count}} staged",
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)", "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.conflicted": "conflict",
"changes.discard": "Discard this file's changes", "changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone", "changes.discardConfirm": "Click again to discard — this cannot be undone",
@@ -113,6 +116,7 @@
"changes.truncated": "Some changes were left out to keep this panel responsive.", "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.unavailable": "This workspace's directory is not a readable git repository.",
"changes.unstage": "Unstage this file", "changes.unstage": "Unstage this file",
"changes.changesView": "Changes",
"changes.wholeFile": "Whole file", "changes.wholeFile": "Whole file",
"confirmRemoveCancel": "Cancel", "confirmRemoveCancel": "Cancel",
"confirmRemoveConfirm": "Remove", "confirmRemoveConfirm": "Remove",
@@ -239,8 +243,8 @@
"changes.undoHunkShortConfirm": "Confirm undo", "changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit", "changes.edit": "Edit",
"changes.save": "Save", "changes.save": "Save",
"changes.revertBlock": "Revert",
"changes.cancelEdit": "Cancel", "changes.cancelEdit": "Cancel",
"changes.clickToEdit": "Click the text to edit",
"changes.preview": "Preview", "changes.preview": "Preview",
"changes.raw": "Raw", "changes.raw": "Raw",
"changes.editorLabel": "Edit {{path}}", "changes.editorLabel": "Edit {{path}}",
+14 -1
View File
@@ -67,7 +67,20 @@
"today": "Today {{cost}}", "today": "Today {{cost}}",
"workspace": "Workspace {{cost}}", "workspace": "Workspace {{cost}}",
"session": "Session {{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", "version": "Version",
"openMenu": "Open menu", "openMenu": "Open menu",
+6 -11
View File
@@ -21,7 +21,7 @@
"import": "Import .codex-pet bundle…", "import": "Import .codex-pet bundle…",
"browse": "Find more on codex-pets.net", "browse": "Find more on codex-pets.net",
"settings": "Pet settings…", "settings": "Pet settings…",
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.", "hiddenToast": "Pet hidden. Show it again in Settings → Appearance.",
"deleteTitle": "Delete {{name}}?", "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.", "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", "deleteConfirm": "Delete pet",
@@ -653,7 +653,10 @@
"reset": "Reset to defaults", "reset": "Reset to defaults",
"resetTitle": "Reset appearance?", "resetTitle": "Reset appearance?",
"resetBody": "Mode goes back to Dark and both modes to their default theme.", "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": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
@@ -753,17 +756,9 @@
"cost": "Cost" "cost": "Cost"
}, },
"section": { "section": {
"general": {
"title": "General",
"description": "The dashboard's display language."
},
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only." "description": "Language, colour mode and theme, and the corner pet."
},
"pet": {
"title": "Pet",
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
}, },
"notifications": { "notifications": {
"title": "Notifications", "title": "Notifications",
+5 -1
View File
@@ -68,7 +68,11 @@
"justNow": "vừa xong", "justNow": "vừa xong",
"mAgo": "{{count}} phút trước", "mAgo": "{{count}} phút trước",
"hAgo": "{{count}} giờ 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": { "pagination": {
"previous": "Trước", "previous": "Trước",
+5 -1
View File
@@ -60,6 +60,9 @@
"changes.commitPlaceholder": "Nội dung commit", "changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage", "changes.commitStaged": "Commit {{count}} file đã stage",
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã 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.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này", "changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác", "changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
@@ -113,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.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.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.unstage": "Bỏ file này khỏi index",
"changes.changesView": "Thay đổi",
"changes.wholeFile": "Toàn bộ file", "changes.wholeFile": "Toàn bộ file",
"confirmRemoveCancel": "Hủy", "confirmRemoveCancel": "Hủy",
"confirmRemoveConfirm": "Xóa", "confirmRemoveConfirm": "Xóa",
@@ -239,8 +243,8 @@
"changes.undoHunkShortConfirm": "Xác nhận", "changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa", "changes.edit": "Sửa",
"changes.save": "Lưu", "changes.save": "Lưu",
"changes.revertBlock": "Hoàn tác",
"changes.cancelEdit": "Huỷ", "changes.cancelEdit": "Huỷ",
"changes.clickToEdit": "Bấm vào nội dung để sửa",
"changes.preview": "Xem trước", "changes.preview": "Xem trước",
"changes.raw": "Mã nguồn", "changes.raw": "Mã nguồn",
"changes.editorLabel": "Sửa {{path}}", "changes.editorLabel": "Sửa {{path}}",
+14 -1
View File
@@ -67,7 +67,20 @@
"today": "Hôm nay {{cost}}", "today": "Hôm nay {{cost}}",
"workspace": "Workspace {{cost}}", "workspace": "Workspace {{cost}}",
"session": "Session {{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", "version": "Phiên bản",
"openMenu": "Mở menu", "openMenu": "Mở menu",
+6 -11
View File
@@ -21,7 +21,7 @@
"import": "Nhập bundle .codex-pet…", "import": "Nhập bundle .codex-pet…",
"browse": "Tìm thêm trên codex-pets.net", "browse": "Tìm thêm trên codex-pets.net",
"settings": "Cài đặt pet…", "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}}?", "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.", "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", "deleteConfirm": "Xoá pet",
@@ -653,7 +653,10 @@
"reset": "Khôi phục mặc định", "reset": "Khôi phục mặc định",
"resetTitle": "Khôi phục giao diện?", "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.", "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": { "gitCreds": {
"title": "Git credentials", "title": "Git credentials",
@@ -753,17 +756,9 @@
"cost": "Chi phí" "cost": "Chi phí"
}, },
"section": { "section": {
"general": {
"title": "Chung",
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
},
"appearance": { "appearance": {
"title": "Giao diện", "title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này." "description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
},
"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."
}, },
"notifications": { "notifications": {
"title": "Thông báo", "title": "Thông báo",
+6
View File
@@ -105,6 +105,10 @@
--syn-plain: var(--fg-primary); --syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */ --syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */ --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 { .dark {
@@ -154,6 +158,8 @@
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */ --syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
--syn-add: 110 231 183; /* emerald-300 */ --syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */ --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 /* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
@@ -40,3 +40,12 @@ describe("changeTree", () => {
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3); 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 }]);
});
});
+6
View File
@@ -390,6 +390,7 @@ import type {
CodexHooksStatus, CodexHooksStatus,
AntigravityHooksStatus, AntigravityHooksStatus,
CostResult, CostResult,
ToolQuota,
DashboardEvent, DashboardEvent,
Lane, Lane,
LaneChangeRange, LaneChangeRange,
@@ -1392,6 +1393,11 @@ export const api = {
applyScope(qs); applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`); 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 * GET /api/pricing/cost/:sessionId - cost for one session, priced as of
* the session's start date. * the session's start date.
+3 -1
View File
@@ -20,7 +20,9 @@ export interface ChangeTreeDir<T> {
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> { export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 }; const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
for (const file of rows) { for (const file of rows) {
const parts = file.path.split("/"); // 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; let dir = root;
dir.count++; dir.count++;
for (const seg of parts.slice(0, -1)) { for (const seg of parts.slice(0, -1)) {
+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 * 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}. * string ("just now", "5m ago", "3h ago", "2d ago") using {@link i18n}.
* @param iso A past timestamp (see {@link parseDate}). * @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`. * @returns A localized relative-time phrase; i18next handles pluralization via `count`.
* @remarks Thresholds cascade seconds -> minutes -> hours -> days (days is the largest * @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 * 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 * "just now" key. Uses translated keys, not `Intl.RelativeTimeFormat`, so the exact
* wording is controlled by the `common:time.*` translation resources. * 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(); const ms = Date.now() - parseDate(iso).getTime();
if (Number.isNaN(ms)) return "-"; 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); 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); 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); 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); 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. * @description Keyboard helpers shared by every single-line submit field.
* `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese * `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese
* Telex, CJK): without it a rename or commit message is committed half-typed. * 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> * @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; const native = "nativeEvent" in e ? e.nativeEvent : e;
return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229; 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; to?: string;
}[] = [ }[] = [
{ {
section: "general", section: "appearance",
titleKey: "language.title", titleKey: "language.title",
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"], keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
}, },
{ {
@@ -90,14 +91,20 @@ export const SETTINGS_INDEX: {
keywords: ["version", "server", "database", "uptime"], keywords: ["version", "server", "database", "uptime"],
}, },
{ {
section: "pet", section: "appearance",
titleKey: "sagi.title", titleKey: "sagi.title",
descKey: "sagi.description", descKey: "sagi.description",
keywords: ["sagi", "mascot", "pet"], 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.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] }, { section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" }, { section: "appearance", titleKey: "appearance.lightThemes" },
+23
View File
@@ -1206,6 +1206,25 @@ export interface CostResult {
unpriced_models?: UnpricedModel[]; 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 ───── // ───── Import progress ─────
/** Payload of the `import.progress` WebSocket message, streamed while a /** Payload of the `import.progress` WebSocket message, streamed while a
@@ -2716,6 +2735,8 @@ export interface LaneCommit {
date: string; date: string;
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */ /** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
refs: string[]; 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. */ /** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
@@ -2795,6 +2816,8 @@ export type LaneChanges =
base: string | null; base: string | null;
/** The ref the base actually resolved to (`origin/main`, `main`, …). */ /** The ref the base actually resolved to (`origin/main`, `main`, …). */
baseRef: string | null; 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. */ /** The commit being shown, for `range: "commit"`; its full sha. */
sha: string | null; sha: string | null;
/** Lines of unchanged context each hunk carries. */ /** Lines of unchanged context each hunk carries. */
+4 -25
View File
@@ -1,6 +1,6 @@
/** /**
* @file Settings.tsx * @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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -205,12 +205,12 @@ function useCountUp(end: number | null, durationMs = 1000) {
// ─── Main component ─── // ─── Main component ───
export function Settings({ export function Settings({
section = "general", section = "notifications",
}: { }: {
/** Which Settings page this renders (`/settings/<section>`). */ /** 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. // Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about"; const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo); const [loading, setLoading] = useState(needsInfo);
@@ -541,27 +541,6 @@ export function Settings({
</div> </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" && ( {section === "notifications" && (
<> <>
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p> <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. * 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; * 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 * 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 * 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 * 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. * disabled. A workspace's own settings live on its home, not here.
@@ -32,18 +33,12 @@ import {
Database, Database,
Info, Info,
Bot, Bot,
Boxes,
Coins, Coins,
GitBranch, GitBranch,
KeyRound, KeyRound,
Orbit,
Palette, Palette,
PawPrint,
Puzzle,
Router, Router,
Search, Search,
Settings as SettingsIcon,
SquareTerminal,
} from "lucide-react"; } from "lucide-react";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig"; import { CcConfig } from "./CcConfig";
@@ -52,7 +47,6 @@ import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig"; import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig"; import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings"; import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { PetSettings } from "../components/settings/PetSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings"; import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings"; import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings"; import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -64,6 +58,7 @@ import {
type SettingsEntry, type SettingsEntry,
} from "../lib/settingsSearch"; } from "../lib/settingsSearch";
import { Skeleton } from "../components/Skeleton"; import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const; 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. */ /** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = { const LEGACY_TOOL: Record<string, string> = {
dashboard: "general", dashboard: "appearance",
"claude-code": "agents/claude-code", "claude-code": "agents/claude-code",
codex: "agents/codex", codex: "agents/codex",
"ai-router": "ai-router", "ai-router": "ai-router",
@@ -104,13 +99,29 @@ const historyIdx = (): number => {
export const backDelta = (current: number, entry: number): number => export const backDelta = (current: number, entry: number): number =>
-Math.max(1, current - entry + 1); -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 }) { function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
useEffect(() => { useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name })); toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]); }, [name, t]);
return <Navigate to="/settings/general" replace />; return <Navigate to="/settings/appearance" replace />;
} }
export function SettingsHub() { export function SettingsHub() {
@@ -191,13 +202,6 @@ export function SettingsHub() {
description: t(`settingsNav.section.${key}.description`), description: t(`settingsNav.section.${key}.description`),
}); });
const sections: Section[] = [ const sections: Section[] = [
{
id: "general",
group: "general",
Icon: SettingsIcon,
...s("general"),
render: () => <Settings section="general" />,
},
{ {
id: "data", id: "data",
group: "general", group: "general",
@@ -219,13 +223,6 @@ export function SettingsHub() {
...s("appearance"), ...s("appearance"),
render: () => <AppearanceSettings />, render: () => <AppearanceSettings />,
}, },
{
id: "pet",
group: "general",
Icon: PawPrint,
...s("pet"),
render: () => <PetSettings />,
},
{ {
id: "notifications", id: "notifications",
group: "general", group: "general",
@@ -245,7 +242,7 @@ export function SettingsHub() {
{ {
id: "agents/claude-code", id: "agents/claude-code",
group: "agents" as const, group: "agents" as const,
Icon: Boxes, Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"), ...s("claudeCode"),
render: () => <CcConfig />, render: () => <CcConfig />,
}, },
@@ -256,7 +253,7 @@ export function SettingsHub() {
{ {
id: "agents/codex", id: "agents/codex",
group: "agents" as const, group: "agents" as const,
Icon: SquareTerminal, Icon: toolNavIcon("codex", "Codex"),
...s("codex"), ...s("codex"),
render: () => <CodexConfig />, render: () => <CodexConfig />,
}, },
@@ -269,7 +266,7 @@ export function SettingsHub() {
.map((x) => ({ .map((x) => ({
id: `agents/${x.id}`, id: `agents/${x.id}`,
group: "agents" as const, group: "agents" as const,
Icon: x.id === "antigravity" ? Orbit : Puzzle, Icon: toolNavIcon(x.id, x.label),
title: x.label, title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }), description: t("settingsNav.section.tool.description", { tool: x.label }),
render: () => <AiToolConfig key={x.id} toolId={x.id} />, render: () => <AiToolConfig key={x.id} toolId={x.id} />,
@@ -349,9 +346,10 @@ export function SettingsHub() {
? "agents" ? "agents"
: tool : tool
? (LEGACY_TOOL[tool] ?? `agents/${tool}`) ? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
: "general"; : "appearance";
return <Navigate to={`/settings/${to}`} replace />; return <Navigate to={`/settings/${to}`} replace />;
} }
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
const active = sections.find((x) => x.id === rest); const active = sections.find((x) => x.id === rest);
if (!active) { if (!active) {
// A catalog tool page exists only once the tool list has loaded. // A catalog tool page exists only once the tool list has loaded.
+8 -1
View File
@@ -1275,6 +1275,7 @@ export function Workspace() {
laneId={panelAt.laneId} laneId={panelAt.laneId}
session={panelAt.session} session={panelAt.session}
full={changesFull} full={changesFull}
wide={changesPreset !== "compact"}
onDirtyChange={setChangesDirty} onDirtyChange={setChangesDirty}
// A sibling worktree forks from the lane's SOURCE repo when // A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an // it has one (this lane is itself a worktree of it); an
@@ -1310,7 +1311,13 @@ export function Workspace() {
{tLanes("changes.title")} {tLanes("changes.title")}
</span> </span>
</button> </button>
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />} {panelAt && (
<LaneGitRailCounts
laneId={panelAt.laneId}
session={panelAt.session}
onShowAll={() => setChangesOpen(true)}
/>
)}
</div> </div>
))} ))}
</div> </div>
+2 -6
View File
@@ -66,12 +66,8 @@ const burst = () => {
}; };
describe("Settings", () => { describe("Settings", () => {
it("never reads server info on Notifications or General, even on activity", async () => { it("never reads server info on Notifications, even on activity", async () => {
const { unmount } = render(<Settings section="notifications" />); render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
unmount();
render(<Settings section="general" />);
burst(); burst();
await new Promise((r) => setTimeout(r, 30)); await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled(); 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`} /> <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("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> })); vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
vi.mock("../AiToolConfig", () => ({ vi.mock("../AiToolConfig", () => ({
@@ -114,7 +117,9 @@ describe("SettingsHub", () => {
}); });
it.each([ it.each([
["/settings", "/settings/general"], ["/settings", "/settings/appearance"],
["/settings/general", "/settings/appearance"],
["/settings/pet", "/settings/appearance"],
["/settings?tool=codex", "/settings/agents/codex"], ["/settings?tool=codex", "/settings/agents/codex"],
["/settings?tool=claude-code", "/settings/agents/claude-code"], ["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"], ["/settings?tool=gemini", "/settings/agents/gemini"],
@@ -125,10 +130,12 @@ describe("SettingsHub", () => {
expect(screen.getByTestId("where").textContent).toBe(to); 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"); renderAt("/settings/nope");
expect(await screen.findByTestId("settings-general")).toBeInTheDocument(); expect(
expect(screen.getByTestId("where").textContent).toBe("/settings/general"); 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 () => { 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 () => { it("navigates from the mobile select", async () => {
renderAt("/settings/general"); renderAt("/settings/data");
await screen.findByTestId("settings-general"); await screen.findByTestId("settings-data");
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), { fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
target: { value: "pricing" }, 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 () => { it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderAt("/settings/general"); renderAt("/settings/data");
await screen.findByTestId("settings-general"); await screen.findByTestId("settings-data");
const box = screen.getByRole("searchbox", { name: "Search settings" }); const box = screen.getByRole("searchbox", { name: "Search settings" });
await user.type(box, "pricing"); await user.type(box, "pricing");
const links = within(nav()) const links = within(nav())
@@ -177,8 +184,8 @@ describe("SettingsHub", () => {
it("says when nothing matches, and Ctrl+F focuses the search", async () => { it("says when nothing matches, and Ctrl+F focuses the search", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderAt("/settings/general"); renderAt("/settings/data");
await screen.findByTestId("settings-general"); await screen.findByTestId("settings-data");
fireEvent.keyDown(window, { key: "f", ctrlKey: true }); fireEvent.keyDown(window, { key: "f", ctrlKey: true });
const box = screen.getByRole("searchbox", { name: "Search settings" }); const box = screen.getByRole("searchbox", { name: "Search settings" });
expect(box).toHaveFocus(); expect(box).toHaveFocus();
@@ -187,8 +194,8 @@ describe("SettingsHub", () => {
}); });
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => { it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general"); renderAt("/settings/data");
await screen.findByTestId("settings-general"); await screen.findByTestId("settings-data");
const dialog = document.createElement("div"); const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog"); dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true"); 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 () => { it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderAt("/settings/general"); renderAt("/settings/data");
await screen.findByTestId("settings-general"); await screen.findByTestId("settings-data");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" }); const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true }); fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet"); await user.type(box, "pet");
@@ -217,8 +224,8 @@ describe("SettingsHub", () => {
it("lists matching rows and deep-links a Claude Code sub-tab", async () => { it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderAt("/settings/general"); renderAt("/settings/data");
await screen.findByTestId("settings-general"); await screen.findByTestId("settings-data");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces"); await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" }); const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!); 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"); renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument(); expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument(); expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav()) const links = within(nav())
.getAllByRole("link") .getAllByRole("link")
.map((a) => a.textContent); .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 () => { it("scrolls to and highlights the anchor the URL names", async () => {
@@ -258,11 +267,11 @@ describe("SettingsHub", () => {
expect(backDelta(0, 2)).toBe(-1); 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[] = []; let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i)); const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope"); renderAt("/settings/nope");
await screen.findByTestId("settings-general"); await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true); expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off(); off();
}); });
@@ -6832,74 +6832,6 @@ exports[`screen snapshots > Sessions 1`] = `
</div> </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`] = ` exports[`screen snapshots > Settings — about 1`] = `
<div> <div>
<div <div
@@ -6981,7 +6913,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p <p
class="text-[10px] text-status-warning/90 mt-1" class="text-[10px] text-status-warning/90 mt-1"
> >
UI build v2.2.0 (rebuild client to match) UI build v2.2.1 (rebuild client to match)
</p> </p>
</div> </div>
<div <div
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react"; 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 { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next"; import i18n from "i18next";
@@ -593,13 +593,9 @@ describe("screen snapshots", () => {
it("Run", async () => { it("Run", async () => {
await snapshot(<Workspace />, "/run"); await snapshot(<Workspace />, "/run");
}); });
it("Settings", async () => {
await snapshot(<Settings />, "/settings");
});
it("Settings — notifications", async () => { it("Settings — notifications", async () => {
await snapshot(<Settings section="notifications" />, "/settings/notifications"); await snapshot(<Settings section="notifications" />, "/settings/notifications");
}); });
// General split into General / Data / About: keep the moved content covered.
it("Settings — data", async () => { it("Settings — data", async () => {
await snapshot(<Settings section="data" />, "/settings/data"); 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): // 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 // they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs. // other sub-tabs.
+4
View File
@@ -49,6 +49,8 @@ const tinted = {
danger: tint("--status-danger"), danger: tint("--status-danger"),
warning: tint("--status-warning"), warning: tint("--status-warning"),
}, },
diff: { mod: tint("--diff-mod") },
kept: tint("--kept"),
}; };
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
@@ -104,6 +106,8 @@ export default {
danger: "rgb(var(--status-danger) / <alpha-value>)", danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <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, backgroundColor: tinted,
borderColor: tinted, borderColor: tinted,
+16 -3
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 `range` picks the comparison: `work` (default) is HEAD vs the working tree with
untracked files included, `branch` is `<base>...HEAD` for the workspace's untracked files included, `branch` is `<base>...HEAD` for the workspace's
`base_branch`, and `commit` shows the single commit named by `sha` (an `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 `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 range the diff has effectively become the whole file, which is what "expand
@@ -509,6 +512,7 @@ that file.
"range": "work", "range": "work",
"base": "development", "base": "development",
"baseRef": "origin/development", "baseRef": "origin/development",
"mergeBase": null,
"sha": null, "sha": null,
"context": 3, "context": 3,
"truncated": false, "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. answer `{available:false}` with HTTP **200** for a cwd git cannot read.
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date, - **`/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 commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. Reading one commit's diff is `400 EBADPATH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`. `/git/changes?range=commit&sha=`.
@@ -2007,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" 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 ### Notifications
@@ -2423,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) 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. 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 ## 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 ```mermaid
sequenceDiagram sequenceDiagram
@@ -237,7 +237,7 @@ npm run test:client
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` | | Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations | | Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting | | 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 ### Operational checklist
- [ ] Confirm all namespaces exist for `en` and `vi` - [ ] Confirm all namespaces exist for `en` and `vi`
- [ ] Confirm key parity across all locale JSON files - [ ] 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 fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
- [ ] Confirm date/time/currency formatting for all supported languages - [ ] Confirm date/time/currency formatting for all supported languages
- [ ] Confirm client tests pass before release - [ ] Confirm client tests pass before release
+37 -19
View File
@@ -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** 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 file, a **rename** (numstat keys those differently), or a repo with no commit
to diff against yet. to diff against yet.
- Hovering (or focusing, or tapping) the `N modified · N untracked` text at the - Hovering (or focusing, or tapping) the vertical `N modified · N untracked` chip
top of the Changes panel opens this list as a panel: one row per file, its status letter 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 colour-coded, the directory truncated before the filename ever is, and each
row's `+N −M` aligned on the right under a total. row's `+N −M` aligned on the right under a total.
- A repo with no commit yet is **not** unavailable: it reports `unborn: true`, - A repo with no commit yet is **not** unavailable: it reports `unborn: true`,
@@ -600,10 +601,9 @@ 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 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 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 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 is dropped; a file opens on its **Changes** like at any width, and
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no **Whole file** is the editor — **Save**/**Cancel** wake up once the text
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel differs, Cancel restores the saved text) → closed (`agenthub.workspace.changesSize`
restores the saved text) → closed (`agenthub.workspace.changesSize`
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it 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 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 which one is "work" changes several times an hour, so the width is a step, not
@@ -627,19 +627,36 @@ reads the workspace's git surface in five modes:
one green with `U` (`A` added, `D` deleted and struck through, `R` 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 renamed), and a folder holding any of them gets an amber dot. The colours
come with the tree read, so **Refresh** re-reads them. come with the tree read, so **Refresh** re-reads them.
Opening a file entry shows the whole file (`/git/file`) for reading, at every A changed file opens on its **Changes**, also when picked in Files; an
panel width; an image file (png, jpg, gif, webp, avif, bmp, ico, svg) is shown unchanged file picked in Files opens as the whole file. Any working file
as the picture (`/git/raw`), with no editor. **Clicking its text** (or Enter on it; any range but a commit) (not a commit's, a binary, a deleted or a conflicted one) is the **editor**
turns that view into a text editor with the caret on the clicked line and in both views (`client/src/components/lanes/DiffEditor.tsx`, CodeMirror 6
the scroll position kept — a click that only ends a drag-selection does not, with `@codemirror/merge`, lazy-loaded), editable, with its diff against
so copying never drops you into the editor. **Cancel** returns to reading. HEAD (`work`) or the branch's merge base (`branch`, `mergeBase` on
From a diff, which has no whole text to click, an **Edit** button does the `/git/changes`): **Changes** folds the unchanged stretches away (a click on
same once the file has loaded. **Save** (or 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 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 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 `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 line endings and final newline; the editor stays open on what was saved. A
over the 1 MB view cap) says so in the file's header. 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 - **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 it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons. row carries its own **stage** / **unstage** / **discard** buttons.
@@ -673,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 **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 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. 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 - **History** — the workspace's recent commits (`GET /:id/git/log`) drawn as a
shows that commit's diff (`?range=commit&sha=`). 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 A range toggle switches the comparison between **Uncommitted** (HEAD vs the
working tree, untracked files included as an all-added patch) and **vs base** working tree, untracked files included as an all-added patch) and **vs base**
@@ -710,7 +728,7 @@ 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 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 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 `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 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 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 refuses it without `confirm: true` and accepts only paths `git status` is
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.2.0 version: 2.2.1
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.2.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.2.0", "version": "2.2.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.2.0", "version": "2.2.1",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+21
View File
@@ -61,6 +61,27 @@ describe("dashboard-runs", () => {
assert.equal(runs.getRun("agenthub-lane-9-1"), null); 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", () => { it("flags as abandoned only the running rows whose pane is gone", () => {
start("agenthub-lane-5", { laneId: 5 }); start("agenthub-lane-5", { laneId: 5 });
start("agenthub-lane-6", { laneId: 6 }); start("agenthub-lane-6", { laneId: 6 });
+3
View File
@@ -195,6 +195,7 @@ describe("collectChanges — branch range", () => {
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true }); const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
assert.equal(out.baseRef, "origin/development"); assert.equal(out.baseRef, "origin/development");
assert.equal(out.mergeBase, g(LANE, "merge-base", "origin/development", "HEAD").trim());
assert.deepEqual( assert.deepEqual(
out.files.map((f) => f.path), out.files.map((f) => f.path),
["a.txt"] ["a.txt"]
@@ -401,6 +402,8 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
["touch keep", "touch a", "init"] ["touch keep", "touch a", "init"]
); );
assert.ok(all[0].refs.includes("HEAD -> feat/thing")); 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" }); const forA = await listCommits(LANE, { file: "a.txt" });
assert.deepEqual( assert.deepEqual(
+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 });
}
});
+1
View File
@@ -491,6 +491,7 @@ module.exports = {
readProfile, readProfile,
writeProfile, writeProfile,
liveCredentialsFor, liveCredentialsFor,
readActiveAuth,
// exported for tests // exported for tests
profilesDir, profilesDir,
configPaths, configPaths,
+18 -1
View File
@@ -263,12 +263,28 @@ const abandonStmt = db.prepare(`
WHERE id = ? AND status IN ('running', 'spawning') 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 * Flags `abandoned` every row still marked running/spawning whose pane is
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted. * 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 * 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 * 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. * Returns the number of rows updated.
*/ */
@@ -278,6 +294,7 @@ function reconcileOrphans(isAlive = () => false) {
for (const { id } of runningStmt.all()) { for (const { id } of runningStmt.all()) {
if (!isAlive(id)) changes += abandonStmt.run(id).changes; if (!isAlive(id)) changes += abandonStmt.run(id).changes;
} }
pruneShellsStmt.run();
return changes; return changes;
} catch { } catch {
return 0; return 0;
+14 -5
View File
@@ -508,7 +508,7 @@ async function synthesizeAddedPatch(dir, file) {
* `file` is set, which is already a full read of one file. * `file` is set, which is already a full read of one file.
* @param {number} [opts.context] Unchanged lines around each hunk. The panel * @param {number} [opts.context] Unchanged lines around each hunk. The panel
* raises this to expand a diff towards the whole file. * 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( async function collectChanges(
dir, dir,
@@ -520,11 +520,17 @@ async function collectChanges(
const ctx = normalizeContext(context); const ctx = normalizeContext(context);
let baseRef = null; 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") { if (range === "branch") {
baseRef = await resolveBaseRef(dir, base); baseRef = await resolveBaseRef(dir, base);
if (!baseRef) { if (!baseRef) {
throw badRequest("ENOBASE", "no base branch to compare against"); 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 (range === "commit") {
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision"); 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 } = {}) { async function listCommits(dir, { limit = 30, file = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 30, 1), 200); const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f"); const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
const args = ["log", `-${n}`, `--format=${format}%x1e`]; // --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 // Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane. // lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file)); 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")) { for (const record of result.stdout.split("\x1e")) {
const line = record.replace(/^\n/, ""); const line = record.replace(/^\n/, "");
if (!line.trim()) continue; 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({ commits.push({
sha, sha,
short, short,
@@ -638,6 +646,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
.split(", ") .split(", ")
.map((r) => r.trim()) .map((r) => r.trim())
.filter(Boolean), .filter(Boolean),
parents: (parents || "").split(" ").filter(Boolean),
}); });
} }
return commits; return commits;
+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 };
+7
View File
@@ -6,6 +6,7 @@
const { Router } = require("express"); const { Router } = require("express");
const { stmts, db } = require("../db"); const { stmts, db } = require("../db");
const { parseSources, sourceColumnClause } = require("../lib/source-filter"); const { parseSources, sourceColumnClause } = require("../lib/source-filter");
const { quotas } = require("../lib/quota");
const { const {
WEB_SEARCH_PER_1K_SEARCHES, WEB_SEARCH_PER_1K_SEARCHES,
CODE_EXEC_PER_HOUR, CODE_EXEC_PER_HOUR,
@@ -508,6 +509,12 @@ router.get("/cost", (req, res) => {
res.json({ ...result, daily_costs }); 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 // GET /api/pricing/cost/:sessionId - Get cost for a specific session
router.get("/cost/:sessionId", (req, res) => { router.get("/cost/:sessionId", (req, res) => {
const rawOffset = parseInt(req.query.tz_offset, 10); const rawOffset = parseInt(req.query.tz_offset, 10);