Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ca1134c3d | |||
| daf5b7be92 | |||
| f0500dc592 |
+1
-1
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
|
||||
Generated
+112
@@ -8,6 +8,10 @@
|
||||
"name": "agenthub-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
@@ -410,6 +414,66 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"style-mod": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.13",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
|
||||
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
@@ -1052,6 +1116,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2580,6 +2674,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -5702,6 +5802,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -6402,6 +6508,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
|
||||
@@ -70,6 +70,7 @@ import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
import { eventBus } from "./lib/eventBus";
|
||||
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||
import type { WSMessage } from "./lib/types";
|
||||
|
||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||
@@ -128,6 +129,12 @@ export default function App() {
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}, []);
|
||||
|
||||
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
return () => window.removeEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
||||
* with Settings → Pet.
|
||||
* with Settings → Appearance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ export function Sagi() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/pet");
|
||||
navigate("/settings/appearance#pet");
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
|
||||
@@ -159,7 +159,7 @@ describe("PetMenu", () => {
|
||||
fireEvent.click(screen.getByText("Hide pet"));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// Says where to bring it back.
|
||||
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
|
||||
expect(toasts.some((x) => /Settings → Appearance/.test(x.message))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`):
|
||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
|
||||
|
||||
@@ -396,13 +396,15 @@ export function Sidebar({
|
||||
onUpdateAvailableChange?.(updateAvailable);
|
||||
}, [updateAvailable, onUpdateAvailableChange]);
|
||||
|
||||
// Same density and selection as a workspace row, so the whole sidebar
|
||||
// reads as one list: no border, the active item tinted with an accent icon.
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
||||
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -451,7 +453,7 @@ export function Sidebar({
|
||||
Chevron buttons appear at the edges when content is clipped, so the
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 space-y-1">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
return (
|
||||
@@ -475,7 +477,7 @@ export function Sidebar({
|
||||
onClick={onToggle}
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FolderTree className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -528,8 +530,8 @@ export function Sidebar({
|
||||
data-testid="sidebar-toggle"
|
||||
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
{collapsed ? (
|
||||
|
||||
@@ -11,22 +11,30 @@
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
||||
* whenever the URL names another workspace or session. Each figure carries its
|
||||
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
|
||||
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
|
||||
* token total, and hovering it lists tokens and cost per model. At the right
|
||||
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
|
||||
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { CostResult } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
|
||||
import type { CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
const QUOTA_POLL_MS = 5 * 60_000;
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -64,6 +72,255 @@ function CostFigure({ testId, label, r }: { testId: string; label: string; r: Co
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-run `load` every `ms` and whenever the tab becomes visible again. */
|
||||
function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) {
|
||||
useEffect(() => {
|
||||
const tick = () => document.visibilityState === "visible" && load();
|
||||
tick();
|
||||
const id = setInterval(tick, ms);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0));
|
||||
const quotaTone = (p: number) =>
|
||||
p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : "";
|
||||
const barTone = (p: number) =>
|
||||
p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted";
|
||||
|
||||
function untilText(iso: string | null): string | null {
|
||||
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||
if (!(ms > 0)) return null;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const d = Math.floor(m / 1440);
|
||||
const h = Math.floor((m % 1440) / 60);
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** "account · sign-in state · as of", whichever parts the entry has. */
|
||||
function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) => string) {
|
||||
const until = untilText(q.expiresAt ?? null);
|
||||
return [
|
||||
q.label && q.account,
|
||||
q.expiresAt &&
|
||||
q.error !== "ELIVEEXPIRED" &&
|
||||
(until
|
||||
? t("statusBar.quotaSignInExpires", { time: until })
|
||||
: t("statusBar.quotaSignInExpired")),
|
||||
q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
const { t } = useTranslation("nav");
|
||||
const meta = quotaMeta(q, t);
|
||||
return (
|
||||
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||
{many && q.active !== false && (
|
||||
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||
{t("statusBar.quotaActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
|
||||
{q.error && !q.windows.length ? (
|
||||
<div className="text-status-warning">
|
||||
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={w.label}>
|
||||
<span className="text-fg-muted">
|
||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||
</span>
|
||||
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${barTone(p)}`}
|
||||
style={{ width: `${Math.min(p, 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
||||
{resets && (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
<span aria-hidden>{resets}</span>
|
||||
<span className="sr-only">
|
||||
{t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaPanel({
|
||||
tools,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
}: {
|
||||
tools: ToolQuota[];
|
||||
/** The footer's Refresh: re-reads the quota, as a click on the chips does. */
|
||||
onRefresh: () => void;
|
||||
/** A forced read is in flight: the Refresh icon spins. */
|
||||
refreshing: boolean;
|
||||
/** The pointer moved onto the panel / off it: it stays open while read. */
|
||||
onEnter: () => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("nav");
|
||||
const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][])
|
||||
.map((id) => [id, tools.filter((q) => q.id === id)] as const)
|
||||
.filter(([, list]) => list.length);
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t("statusBar.quotaTitle")}
|
||||
data-testid="status-bar-quota-panel"
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onLeave}
|
||||
onFocus={onEnter}
|
||||
onBlur={onLeave}
|
||||
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
>
|
||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||
{groups.map(([id, list]) => (
|
||||
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
|
||||
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
|
||||
</div>
|
||||
<div className="space-y-2 pl-[1.375rem]">
|
||||
{list.map((q, i) => (
|
||||
<QuotaAccount
|
||||
key={`${q.label ?? q.account ?? ""}-${i}`}
|
||||
q={q}
|
||||
many={list.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="status-bar-quota-refresh"
|
||||
// Not `disabled`: a focused button that turns disabled loses focus, and
|
||||
// that blur would close the panel under the pointer.
|
||||
onClick={() => !refreshing && onRefresh()}
|
||||
aria-busy={refreshing}
|
||||
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
|
||||
refreshing ? "cursor-default" : "hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
|
||||
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||
function QuotaChips() {
|
||||
const { t } = useTranslation("nav");
|
||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
};
|
||||
const hide = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||
};
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const load = useCallback((force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined)
|
||||
.finally(() => force && setRefreshing(false));
|
||||
}, []);
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="status-bar-quota"
|
||||
data-tip-off
|
||||
onClick={() => load(true)}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
|
||||
>
|
||||
{tools
|
||||
.filter((q) => q.active !== false)
|
||||
.map((q) => {
|
||||
const p = Math.round(fullest(q));
|
||||
return (
|
||||
<span key={q.id} className="flex items-center gap-1">
|
||||
<AiToolIcon
|
||||
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
|
||||
className="h-4 w-4 !p-0"
|
||||
/>
|
||||
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
|
||||
{q.error ? (
|
||||
<span className="text-status-warning">!</span>
|
||||
) : (
|
||||
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</button>
|
||||
{open && (
|
||||
<QuotaPanel
|
||||
tools={tools}
|
||||
onEnter={show}
|
||||
onLeave={hide}
|
||||
onRefresh={() => load(true)}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
wsConnected,
|
||||
left,
|
||||
@@ -213,6 +470,7 @@ export function StatusBar({
|
||||
r={sessionCost}
|
||||
/>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
||||
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
|
||||
* a dismissed one stays dismissed. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
* a dismissed one stays dismissed. The connection modal's "Check for updates"
|
||||
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
|
||||
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -184,7 +185,11 @@ export function UpdateNotifier() {
|
||||
}, [syncFromPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setDismissedSha(null);
|
||||
// An explicit "Check for updates" undoes both kinds of close.
|
||||
const handler = () => {
|
||||
setDismissedSha(null);
|
||||
setClosedKey(null);
|
||||
};
|
||||
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||
}, []);
|
||||
@@ -313,7 +318,7 @@ export function UpdateNotifier() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-notifier-title"
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* state with the version (click opens the connection details), the running and
|
||||
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
|
||||
* with its tokens and a per-model hover list, and on the Workspace page the
|
||||
* open workspace's and the shown session's totals.
|
||||
* open workspace's and the shown session's totals; the right-end quota chips
|
||||
* (fullest window per tool) and their hover panel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -68,6 +69,42 @@ vi.mock("../../lib/api", () => ({
|
||||
],
|
||||
daily_costs: [],
|
||||
})),
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
id: "claude",
|
||||
active: true,
|
||||
label: "Work",
|
||||
account: "me@example.com",
|
||||
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 95,
|
||||
resetsAt: new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(),
|
||||
},
|
||||
{ label: "wk", usedPercent: 32, resetsAt: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
active: false,
|
||||
label: "Personal",
|
||||
account: "home@example.com",
|
||||
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||
windows: [],
|
||||
error: "EPROFILEEXPIRED",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
account: "dev@example.com",
|
||||
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||
windows: [],
|
||||
error: "EUPSTREAM",
|
||||
},
|
||||
],
|
||||
checkedAt: "",
|
||||
}),
|
||||
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
|
||||
total_cost: id === "s-live" ? 4.5 : 7,
|
||||
breakdown: [],
|
||||
@@ -144,4 +181,52 @@ describe("StatusBar", () => {
|
||||
});
|
||||
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows each tool's fullest quota window and the details on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chips = await screen.findByTestId("status-bar-quota");
|
||||
expect(chips).toHaveTextContent("Claude95% used");
|
||||
expect(chips.textContent?.match(/Claude/g)).toHaveLength(1);
|
||||
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
||||
await user.hover(chips);
|
||||
const panel = screen.getByTestId("status-bar-quota-panel");
|
||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workin use");
|
||||
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||
expect(work).toHaveTextContent("5h95%41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
expect(personal).not.toHaveTextContent("in use");
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
expect(panel).toHaveTextContent("Could not read the quota");
|
||||
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
||||
await user.unhover(chips);
|
||||
await user.hover(panel);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||
// Only the footer button refreshes: it spins and says so until the read lands.
|
||||
const same = await api.pricing.quota(false);
|
||||
let answer!: (v: unknown) => void;
|
||||
vi.mocked(api.pricing.quota)
|
||||
.mockClear()
|
||||
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never);
|
||||
const refresh = screen.getByTestId("status-bar-quota-refresh");
|
||||
await user.click(refresh);
|
||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||
expect(refresh).toHaveTextContent("Refreshing…");
|
||||
expect(refresh).toHaveAttribute("aria-busy", "true");
|
||||
await user.click(refresh);
|
||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||
await act(async () => answer(same));
|
||||
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
|
||||
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||
await user.unhover(panel);
|
||||
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
||||
vi.mocked(api.pricing.quota).mockClear();
|
||||
await user.click(chips);
|
||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,6 +238,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
|
||||
});
|
||||
|
||||
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
@@ -82,6 +82,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
kill: vi.fn(async () => ({ ok: true })),
|
||||
history: vi.fn(async () => ({ items: [] })),
|
||||
forgetHistory: vi.fn(async () => ({ removed: "" })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
@@ -417,6 +418,30 @@ describe("WorkspaceTree", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
|
||||
});
|
||||
|
||||
it("removes a closed terminal from its hover X and its right-click menu", async () => {
|
||||
const shell = (id: string) => ({
|
||||
id,
|
||||
tool: "shell",
|
||||
status: "exited",
|
||||
started_at: "2026-10-06T08:00:00Z",
|
||||
ended_at: "2026-10-06T08:05:00Z",
|
||||
isLive: false,
|
||||
});
|
||||
vi.mocked(api.run.history).mockImplementation(
|
||||
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
|
||||
await user.click(screen.getByText("Remove from list"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
|
||||
});
|
||||
|
||||
it("selects by URL, and a click selects and expands", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree("/workspace?lane=2");
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
|
||||
* file's original text (HEAD, or a branch's merge base), editable while the
|
||||
* diff re-colours as you type. `unified` is one editor with the removed lines
|
||||
* inline above their replacement (`unifiedMergeView`); `split` is the
|
||||
* original read-only on the left beside the editable file (`MergeView`).
|
||||
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
||||
* and every changed block carries a Revert that puts the original back in the
|
||||
* editor (nothing is written until Save). Green marks a line added whole, red
|
||||
* a line removed whole, blue a line edited in place (it keeps some of its
|
||||
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { highlight, tokenClass } from "../../lib/highlight";
|
||||
|
||||
/**
|
||||
* True when a letter or digit of a line (`text`, starting at document offset
|
||||
* `from`) lies outside every changed range: an edit inside the line, not a
|
||||
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
|
||||
* a `}`) does not make two lines the same line.
|
||||
*/
|
||||
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (!/\w/.test(text[i]!)) continue;
|
||||
const at = from + i;
|
||||
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const modLine = Decoration.line({ class: "cm-ah-mod" });
|
||||
const marks = new Map<string, Decoration>();
|
||||
const mark = (cls: string) => {
|
||||
let d = marks.get(cls);
|
||||
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
|
||||
return d;
|
||||
};
|
||||
|
||||
/** Syntax marks and the edited-in-place class for the visible lines — on
|
||||
* either side of a split view (`a` is the original). */
|
||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
const info = getChunks(view.state);
|
||||
const a = info?.side === "a";
|
||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||
const chunks = info?.chunks ?? [];
|
||||
const doc = view.state.doc;
|
||||
let last = 0;
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (let pos = from; pos <= to; ) {
|
||||
const line = doc.lineAt(pos);
|
||||
pos = line.to + 1;
|
||||
if (line.number <= last) continue;
|
||||
last = line.number;
|
||||
const c = chunks.find((k) => {
|
||||
const [from, to, end] = span(k);
|
||||
return to! > from! && line.from >= from! && line.from <= end!;
|
||||
});
|
||||
const at0 = c ? span(c)[0]! : 0;
|
||||
if (
|
||||
c &&
|
||||
keepsText(
|
||||
line.text,
|
||||
line.from,
|
||||
c.changes.map((ch) =>
|
||||
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
|
||||
)
|
||||
)
|
||||
) {
|
||||
out.add(line.from, line.from, modLine);
|
||||
}
|
||||
let at = line.from;
|
||||
for (const tok of highlight(line.text, lang)) {
|
||||
const cls = tokenClass(tok.type);
|
||||
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
|
||||
at += tok.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.finish();
|
||||
}
|
||||
|
||||
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
|
||||
* classed after the fact, by `keepsText`'s rule: text outside its
|
||||
* `.cm-deletedText` spans is text the line kept. */
|
||||
function classDeleted(root: HTMLElement) {
|
||||
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
|
||||
let kept = "";
|
||||
(line.firstElementChild ?? line).childNodes.forEach((n) => {
|
||||
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
|
||||
kept += n.textContent ?? "";
|
||||
}
|
||||
});
|
||||
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
|
||||
});
|
||||
}
|
||||
|
||||
const diffColours = (lang: string) =>
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = decorate(view, lang);
|
||||
this.classLater(view);
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
|
||||
this.classLater(u.view);
|
||||
}
|
||||
// After CodeMirror has drawn, and before the browser paints.
|
||||
classLater(view: EditorView) {
|
||||
view.requestMeasure({
|
||||
key: this,
|
||||
read: () => null,
|
||||
write: () => classDeleted(view.contentDOM),
|
||||
});
|
||||
}
|
||||
},
|
||||
{ decorations: (p) => p.decorations }
|
||||
);
|
||||
|
||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||
const RED = "rgb(var(--status-danger) / 0.18)";
|
||||
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
||||
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
|
||||
// base theme, whose selectors carry an extra light/dark scope class.
|
||||
const M = "&.cm-merge-b.cm-merge-b";
|
||||
const A = "&.cm-merge-a.cm-merge-a";
|
||||
const theme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
color: "rgb(var(--fg-primary))",
|
||||
},
|
||||
"&.cm-focused": { outline: "none" },
|
||||
".cm-scroller": {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
|
||||
lineHeight: "1.45",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
color: "rgb(var(--fg-muted))",
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||
},
|
||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-changedText`]: { background: "none" },
|
||||
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
|
||||
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
|
||||
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
|
||||
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
|
||||
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
|
||||
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||
// The original, left of a split view: its changed lines are removals.
|
||||
[`${A} .cm-changedLine`]: { backgroundColor: RED },
|
||||
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${A} .cm-changedText`]: { background: "none" },
|
||||
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||
"&.cm-editor.cm-editor .cm-collapsedLines": {
|
||||
color: "rgb(var(--fg-muted))",
|
||||
background: "rgb(var(--surface-2))",
|
||||
},
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
});
|
||||
|
||||
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
||||
function revertButton(label: string, arrow = false) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = arrow ? "→" : label;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.className =
|
||||
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
|
||||
return b;
|
||||
}
|
||||
|
||||
export default function DiffEditor({
|
||||
value,
|
||||
original,
|
||||
lang,
|
||||
layout = "unified",
|
||||
collapse = false,
|
||||
label,
|
||||
revertLabel,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
original: string;
|
||||
lang: string;
|
||||
/** One editor with removals inline, or the original beside the file. */
|
||||
layout?: "unified" | "split";
|
||||
/** Fold unchanged stretches away: only the changes and their context. */
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
/** The text of each changed block's Revert button. */
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
|
||||
useEffect(() => {
|
||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||
const common = [lineNumbers(), diffColours(lang), theme];
|
||||
const editing = [
|
||||
...common,
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
cb.current.onSave();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
}),
|
||||
];
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
if (layout === "split") {
|
||||
const mv = new MergeView({
|
||||
parent: host.current!,
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
},
|
||||
b: { doc: value, extensions: editing },
|
||||
revertControls: "a-to-b",
|
||||
renderRevertControl: () => revertButton(revertLabel, true),
|
||||
collapseUnchanged: folded,
|
||||
});
|
||||
v = mv.b;
|
||||
destroy = () => mv.destroy();
|
||||
} else {
|
||||
v = new EditorView({
|
||||
parent: host.current!,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
...editing,
|
||||
unifiedMergeView({
|
||||
original,
|
||||
syntaxHighlightDeletions: false,
|
||||
collapseUnchanged: folded,
|
||||
// Accept would only hide a block; Revert is the one that edits.
|
||||
mergeControls: (type, action) => {
|
||||
const b = revertButton(revertLabel);
|
||||
if (type === "accept") b.hidden = true;
|
||||
else b.onmousedown = action;
|
||||
return b;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
destroy = () => v.destroy();
|
||||
}
|
||||
v.focus();
|
||||
view.current = v;
|
||||
return destroy;
|
||||
// One mount per view: the parent keys this component by file and shape.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
if (v && v.state.doc.toString() !== value) {
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={host}
|
||||
data-testid="lane-file-editor"
|
||||
data-layout={layout}
|
||||
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
const MIN_ROW: CSSProperties = { minHeight: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
@@ -165,7 +166,7 @@ function Windowed({
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
function langFor(filePath: string) {
|
||||
export function langFor(filePath: string) {
|
||||
const ext = filePath.split(".").pop() || "";
|
||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||
}
|
||||
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-fg-primary/15">
|
||||
<span className="rounded-sm bg-diff-mod/40">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
@@ -209,9 +210,10 @@ function emphasis(
|
||||
while (start < max && del[start] === add[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
||||
// Nothing in common at either edge: the whole line is the change, and
|
||||
// highlighting all of it adds noise on top of the row tint.
|
||||
if (start === 0 && end === 0) return null;
|
||||
// No letter or digit in common at either edge (blanks, a `;`, a `}` do not
|
||||
// count): the whole line is the change — a removed line and an added one,
|
||||
// not an edit — and highlighting all of it adds noise on top of the row tint.
|
||||
if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null;
|
||||
return {
|
||||
del: [start, del.length - end],
|
||||
add: [start, add.length - end],
|
||||
@@ -294,7 +296,8 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||||
* `style` pins a windowed row's height; an unwindowed row is at least one
|
||||
* line tall, so a split view's empty filler row keeps its column in step. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
@@ -310,7 +313,7 @@ function Row({
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
style={style ?? MIN_ROW}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
data-change-index={anchor}
|
||||
>
|
||||
@@ -320,15 +323,19 @@ function Row({
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not). */
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not).
|
||||
* Green/red mark a line added or removed whole; blue (`mod`) a line edited in
|
||||
* place — a -/+ pair that keeps part of its text, its edit emphasised. */
|
||||
function rowTint(type: LaneDiffLine["type"] | null, mod = false) {
|
||||
if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]";
|
||||
if (type === "add")
|
||||
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
|
||||
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
|
||||
return "";
|
||||
}
|
||||
|
||||
function markerClass(type: LaneDiffLine["type"]) {
|
||||
function markerClass(type: LaneDiffLine["type"], mod = false) {
|
||||
if (mod && type !== "ctx") return "font-bold text-diff-mod";
|
||||
if (type === "add") return "font-bold text-status-success";
|
||||
if (type === "del") return "font-bold text-status-danger";
|
||||
return "text-fg-muted";
|
||||
@@ -485,11 +492,11 @@ function UnifiedLine({
|
||||
}) {
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line.type, !!emph)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} commentable />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
<span className={`select-none ${markerClass(line.type, !!emph)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
@@ -558,7 +565,7 @@ function SplitLine({
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
@@ -941,16 +948,12 @@ export function LaneFileView({
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
onEditAt,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Set when the file is editable: a click on the text (0-based line, and
|
||||
* where the view was scrolled) asks to open the editor there. */
|
||||
onEditAt?: (line: number, scrollTop: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
@@ -959,12 +962,9 @@ export function LaneFileView({
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
||||
<span data-ln={i} className="contents">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
@@ -972,27 +972,7 @@ export function LaneFileView({
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
tabIndex={onEditAt ? 0 : undefined}
|
||||
onClick={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
// A click that ends a drag-selection is copying, not asking to edit.
|
||||
if (window.getSelection()?.toString()) return;
|
||||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
||||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
onKeyDown={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
onEditAt(0, e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
||||
onEditAt ? " cursor-text" : ""
|
||||
}`}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts) and the git action bar under it
|
||||
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
|
||||
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
|
||||
* the open panel lists those files itself, so its header chip opens nothing.
|
||||
* head, subject) and the git action bar under it (`LaneGitBar`); plus the
|
||||
* uncommitted counts drawn vertical on the CLOSED Changes rail
|
||||
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
|
||||
* by the real total, listing the server's first 20, its "… more files" opening
|
||||
* the Changes panel — the open panel lists those files itself, so its header
|
||||
* shows no counts.
|
||||
*
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
@@ -102,6 +104,7 @@ const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
onShowAll,
|
||||
anchor,
|
||||
side = "below",
|
||||
onPointerEnter,
|
||||
@@ -109,6 +112,8 @@ function LaneGitFilesPanel({
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** Opens the Changes panel, which lists every file the cap left out. */
|
||||
onShowAll?: () => void;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
/** `left` beside a chip on the right-edge rail, where below would cover it. */
|
||||
@@ -146,13 +151,14 @@ function LaneGitFilesPanel({
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
const total = files.length + hidden;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
aria-label={t("git.filesHeading", { count: total })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
@@ -169,7 +175,7 @@ function LaneGitFilesPanel({
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
@@ -206,30 +212,40 @@ function LaneGitFilesPanel({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
{hidden > 0 &&
|
||||
(onShowAll ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-git-files-more"
|
||||
onClick={onShowAll}
|
||||
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The uncommitted counts as a chip. With `details` it opens the per-file panel
|
||||
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
|
||||
* else lists the files. In the open panel the list below already does, so the
|
||||
* header shows the chip alone.
|
||||
* The uncommitted counts as a chip that opens the per-file panel on hover,
|
||||
* focus or click — the closed Changes rail, `vertical`, where nothing else
|
||||
* lists the files.
|
||||
*/
|
||||
export function LaneGitCounts({
|
||||
git,
|
||||
vertical = false,
|
||||
details = true,
|
||||
onShowAll,
|
||||
}: {
|
||||
git: Extract<LaneGitFacts, { available: true }>;
|
||||
vertical?: boolean;
|
||||
details?: boolean;
|
||||
/** See `LaneGitFilesPanel`. */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
@@ -253,13 +269,6 @@ export function LaneGitCounts({
|
||||
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
|
||||
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
|
||||
}`;
|
||||
if (!details) {
|
||||
return (
|
||||
<span data-testid="lane-git-counts" className={chip}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -288,6 +297,13 @@ export function LaneGitCounts({
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
onShowAll={
|
||||
onShowAll &&
|
||||
(() => {
|
||||
setFilesOpen(false);
|
||||
onShowAll();
|
||||
})
|
||||
}
|
||||
anchor={anchor}
|
||||
side={vertical ? "left" : "below"}
|
||||
onPointerEnter={openFiles}
|
||||
@@ -300,9 +316,18 @@ export function LaneGitCounts({
|
||||
|
||||
/** The counts on the closed Changes rail: its own facts read, polled only
|
||||
* while the rail is shown (the open panel owns the read otherwise). */
|
||||
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
|
||||
export function LaneGitRailCounts({
|
||||
laneId,
|
||||
session,
|
||||
onShowAll,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
|
||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
@@ -339,7 +364,6 @@ export function LaneGitHeader({
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
<LaneGitCounts git={git} details={false} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -160,7 +160,7 @@ export function WorkspaceRow({
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||
* which never has a session) as **Terminal** — and under the sessions the last
|
||||
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
||||
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
|
||||
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
|
||||
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
@@ -31,7 +32,7 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
||||
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
||||
@@ -42,7 +43,7 @@ import { timeAgo } from "../../lib/format";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
|
||||
export const SESSION_LIMIT = 6;
|
||||
@@ -297,6 +298,15 @@ export function WorkspaceSessionList({
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const closedCtx = useContextMenu<RunHistoryItem>();
|
||||
const forgetShell = async (r: RunHistoryItem) => {
|
||||
try {
|
||||
await api.run.forgetHistory(r.id);
|
||||
setClosedShells((all) => all.filter((x) => x.id !== r.id));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
@@ -427,7 +437,7 @@ export function WorkspaceSessionList({
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
</>
|
||||
@@ -435,7 +445,11 @@ export function WorkspaceSessionList({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
<ul
|
||||
data-testid={`workspace-sessions-${lane.id}`}
|
||||
// A guide line under the workspace's chevron; the rows start past it.
|
||||
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
|
||||
>
|
||||
{fresh.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
@@ -450,7 +464,7 @@ export function WorkspaceSessionList({
|
||||
}
|
||||
onContextMenu={(e) => ctx.open(e, r)}
|
||||
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
r.id === selectedRunId
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -458,19 +472,19 @@ export function WorkspaceSessionList({
|
||||
>
|
||||
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
||||
<span className="sr-only">{t("tree.state.live")}</span>
|
||||
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
|
||||
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
|
||||
{r.startedAt ? timeAgo(r.startedAt, true) : null}
|
||||
</span>
|
||||
{r.tool === "shell" ? (
|
||||
<>
|
||||
<span className="truncate">{t("tabs.terminal")}</span>
|
||||
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
</>
|
||||
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate italic">{t("tree.newSession")}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
</>
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
@@ -480,7 +494,7 @@ export function WorkspaceSessionList({
|
||||
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
||||
if (renaming === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||
<li key={s.id} className="py-0.5 pr-2">
|
||||
<input
|
||||
data-testid={`workspace-session-rename-${s.id}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
@@ -534,7 +548,7 @@ export function WorkspaceSessionList({
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
: ""
|
||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -554,9 +568,12 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
@@ -575,13 +592,14 @@ export function WorkspaceSessionList({
|
||||
);
|
||||
})}
|
||||
{closed.map((r) => (
|
||||
<li key={r.id}>
|
||||
<li key={r.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-terminal-closed-${r.id}`}
|
||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
||||
onContextMenu={(e) => closedCtx.open(e, r)}
|
||||
title={t("tree.closedTerminal")}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -590,9 +608,22 @@ export function WorkspaceSessionList({
|
||||
<span className="sr-only">{t("tree.state.idle")}</span>
|
||||
<span className="truncate">{t("tabs.terminal")}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px]">
|
||||
{timeAgo(r.ended_at ?? r.started_at)}
|
||||
{timeAgo(r.ended_at ?? r.started_at, true)}
|
||||
</span>
|
||||
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
<SquareTerminal
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-terminal-forget-${r.id}`}
|
||||
onClick={() => void forgetShell(r)}
|
||||
title={t("tree.forgetTerminal")}
|
||||
aria-label={t("tree.forgetTerminal")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -602,7 +633,7 @@ export function WorkspaceSessionList({
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-more-${lane.id}`}
|
||||
onClick={() => own && setLimit(own.total)}
|
||||
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</button>
|
||||
@@ -619,6 +650,42 @@ export function WorkspaceSessionList({
|
||||
onClose={ctx.close}
|
||||
/>
|
||||
)}
|
||||
{closedCtx.menu && (
|
||||
<ContextMenu
|
||||
x={closedCtx.menu.x}
|
||||
y={closedCtx.menu.y}
|
||||
onClose={closedCtx.close}
|
||||
testId={`terminal-menu-${closedCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
closedCtx.close();
|
||||
void reopenShell(false);
|
||||
}}
|
||||
>
|
||||
{t("tree.reopenTerminal")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
closedCtx.close();
|
||||
void reopenShell(true);
|
||||
}}
|
||||
>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const item = closedCtx.menu!.item;
|
||||
closedCtx.close();
|
||||
void forgetShell(item);
|
||||
}}
|
||||
>
|
||||
{t("tree.forgetTerminal")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{sessionCtx.menu && (
|
||||
<SessionMenu
|
||||
x={sessionCtx.menu.x}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||
* count as edited in place (blue) rather than added or removed whole, and the
|
||||
* editor's own diff classes on a real mount.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
|
||||
Range.prototype.getClientRects ??= () => EMPTY;
|
||||
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
|
||||
|
||||
describe("keepsText", () => {
|
||||
it("is false for a line whose every non-blank character changed", () => {
|
||||
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
|
||||
expect(keepsText("", 0, [])).toBe(false);
|
||||
// Only a `;` survived: not the same line.
|
||||
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when some text outside the changes survives", () => {
|
||||
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiffEditor", () => {
|
||||
it("marks a whole new line green and a line edited in place blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"keep\nconst x = 2;\nbrand new"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
|
||||
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
|
||||
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
|
||||
);
|
||||
});
|
||||
|
||||
it("side by side: the original read-only on the left, its edited line blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
value={"keep\nconst x = 2;"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
|
||||
expect(left).toHaveAttribute("contenteditable", "false");
|
||||
expect(right).toHaveAttribute("contenteditable", "true");
|
||||
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
// The revert column is drawn in CodeMirror's next measure pass, not at mount.
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert")
|
||||
);
|
||||
});
|
||||
|
||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
collapse
|
||||
value={`${same}\nchanged`}
|
||||
original={`${same}\noriginal`}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,42 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
original: string;
|
||||
layout?: string;
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
onChange: (v: string) => void;
|
||||
onSave: () => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="lane-file-editor"
|
||||
data-original={p.original}
|
||||
data-layout={p.layout}
|
||||
data-collapse={String(!!p.collapse)}
|
||||
aria-label={p.label}
|
||||
autoFocus
|
||||
value={p.value}
|
||||
onChange={(e) => p.onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
p.onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
@@ -32,6 +61,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitBranches: vi.fn(),
|
||||
changes: vi.fn(),
|
||||
gitAction: vi.fn(),
|
||||
suggestCommitMessage: vi.fn(),
|
||||
gitLog: vi.fn(),
|
||||
gitFile: vi.fn(),
|
||||
gitRawUrl: (id: number, path: string, rev: string) =>
|
||||
@@ -90,14 +120,20 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
|
||||
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
name: "Discard this file's changes",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -113,6 +149,7 @@ beforeEach(() => {
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
parents: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -140,8 +177,8 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
|
||||
* panel — the tab is the Workspace's state, passed in as `tab`. */
|
||||
/** The panel as the Workspace renders it: Files and Changes are its own mode
|
||||
* buttons now, no activity-bar tab around it. */
|
||||
function Tabbed({
|
||||
laneId,
|
||||
onDirtyChange,
|
||||
@@ -149,18 +186,7 @@ function Tabbed({
|
||||
laneId: number;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"files" | "changes">("changes");
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setTab("files")}>
|
||||
Files
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("changes")}>
|
||||
Changes
|
||||
</button>
|
||||
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
|
||||
</>
|
||||
);
|
||||
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
|
||||
}
|
||||
|
||||
describe("LaneChangesPanel", () => {
|
||||
@@ -169,7 +195,7 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
|
||||
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(screen.getByText("+12")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/new line/)).toBeNull();
|
||||
});
|
||||
@@ -194,7 +220,7 @@ describe("LaneChangesPanel", () => {
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
@@ -203,6 +229,7 @@ describe("LaneChangesPanel", () => {
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
await toDiff(user);
|
||||
// The highlighter splits a line into one span per token, so assert on the
|
||||
// rendered text of the whole view rather than on a single node.
|
||||
const view = await screen.findByTestId("lane-diff-view");
|
||||
@@ -258,6 +285,65 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
||||
await user.click(keep!); // the first row: server/lib/git-ops.js
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
||||
"server/lib/git-ops.js",
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||
// And back: the row's button now offers the way out.
|
||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||
});
|
||||
|
||||
it("draws a never-stage file orange in the Files tree", async () => {
|
||||
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
});
|
||||
|
||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
// `server/lib` holds nothing but `lib`: one compacted row.
|
||||
const folder = within(list).getByTitle("server/lib");
|
||||
expect(folder).toHaveTextContent("1 file");
|
||||
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
|
||||
await user.click(folder);
|
||||
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
await user.click(
|
||||
within(folder.parentElement!).getByRole("button", {
|
||||
name: "Stage every file in this folder",
|
||||
})
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({
|
||||
available: true,
|
||||
@@ -341,16 +427,26 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fills the commit box with the AI-suggested message", async () => {
|
||||
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTestId("lane-changes-suggest-message"));
|
||||
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
|
||||
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
||||
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -374,12 +470,12 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
@@ -408,7 +504,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -433,29 +529,29 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// Opening a file only shows it: no editor until the text itself is clicked.
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
await user.click(view);
|
||||
// Opening a file is editing it, diffed against its HEAD text.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(editor).toHaveFocus();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
|
||||
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel asks before dropping an edit: "keep" keeps it…
|
||||
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
||||
// …and "discard" drops it and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// …and "discard" reverts it, still editing.
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
||||
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
@@ -468,32 +564,41 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
// Changes is the editor folded to its changes, side by side at full width.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("a");
|
||||
expect(editor).toHaveAttribute("data-collapse", "true");
|
||||
expect(editor).toHaveAttribute("data-layout", "split");
|
||||
await user.type(editor, "b");
|
||||
// Whole file unfolds the same edit — no prompt, nothing dropped.
|
||||
vi.mocked(confirmDiscard).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
||||
const whole = await screen.findByTestId("lane-file-editor");
|
||||
expect(whole).toHaveAttribute("data-collapse", "false");
|
||||
expect(whole).toHaveValue("ab");
|
||||
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
|
||||
expect(confirmDiscard).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
||||
it("opens diffs unified at the narrow width", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["copy me"],
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "copy",
|
||||
} as unknown as Selection);
|
||||
await user.click(view);
|
||||
spy.mockRestore();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
@@ -517,7 +622,7 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByText("server/lib/git-ops.js");
|
||||
const row = within(list).getByTitle("server/lib/git-ops.js");
|
||||
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
||||
|
||||
await right();
|
||||
@@ -577,9 +682,9 @@ describe("LaneChangesPanel", () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
// A plain refresh (the poll's twin), not a git action — an action already disarms.
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
|
||||
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await screen.findByText("server/lib/git-ops.js");
|
||||
await screen.findByTitle("server/lib/git-ops.js");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
||||
).toBeNull();
|
||||
@@ -612,7 +717,7 @@ describe("LaneChangesPanel", () => {
|
||||
resolveOld(changes());
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
||||
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a commit from history as a commit-range review", async () => {
|
||||
@@ -621,6 +726,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -766,7 +872,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
await user.clear(editor);
|
||||
@@ -779,36 +884,9 @@ describe("LaneChangesPanel", () => {
|
||||
"const whole = 2;",
|
||||
"b".repeat(40)
|
||||
);
|
||||
// Back to reading, showing what was saved.
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
// The Workspace asked before switching tab; the edit must then be GONE, or
|
||||
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
|
||||
it("drops an edited file when the tab switches away from it", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const dirty = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "x");
|
||||
expect(dirty).toHaveBeenLastCalledWith(true);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// Still editing, on what was saved: nothing left to save.
|
||||
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
|
||||
});
|
||||
|
||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||
@@ -852,7 +930,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
@@ -892,7 +969,8 @@ describe("LaneChangesPanel", () => {
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||
@@ -903,7 +981,8 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||
|
||||
// Collapse is dead at the tightest step, and live once expanded — an
|
||||
@@ -933,8 +1012,8 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
@@ -995,11 +1074,10 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
|
||||
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
||||
for (const name of ["Overview", "Review", "History"]) {
|
||||
for (const name of ["Changes", "Files", "Review", "History"]) {
|
||||
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
@@ -1351,9 +1429,9 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
const before = screen.getByText("server/lib/git-ops.js");
|
||||
const before = screen.getByTitle("server/lib/git-ops.js");
|
||||
await user.type(screen.getByLabelText("Commit message"), "x");
|
||||
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
|
||||
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
|
||||
});
|
||||
|
||||
it("expands all folders with ONE request, not one per folder", async () => {
|
||||
@@ -1418,7 +1496,7 @@ describe("LaneChangesPanel", () => {
|
||||
lane7Reads
|
||||
);
|
||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
||||
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
|
||||
|
||||
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||
// Two lines removed, none added: the right column's two rows are fillers.
|
||||
const file = fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||
]);
|
||||
render(<LaneDiffView file={file} layout="split" />);
|
||||
const fillers = [
|
||||
...screen
|
||||
.getByTestId("lane-diff-split-right")
|
||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
expect(fillers).toHaveLength(2);
|
||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
// A line added or removed whole: nothing in common with its pair.
|
||||
const WHOLE = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
|
||||
]);
|
||||
|
||||
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const add = rowOf(container, "xyz");
|
||||
const del = rowOf(container, "abc");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
|
||||
});
|
||||
|
||||
it("tints a line edited in place blue on both sides", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
for (const text of ["x = 1", "x = 2"]) {
|
||||
const row = rowOf(container, text);
|
||||
expect(row.className).toContain("bg-diff-mod/20");
|
||||
expect(row.className).not.toMatch(/status-(success|danger)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const plus = marker(rowOf(container, "xyz"));
|
||||
const minus = marker(rowOf(container, "abc"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
it("renders the live branch, head and subject", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("shows the counts in the open panel's header without the hover list", async () => {
|
||||
it("opens the Changes panel from the file list's “more files”", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
const onShowAll = vi.fn();
|
||||
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
fireEvent.click(screen.getByTestId("lane-git-files-more"));
|
||||
expect(onShowAll).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no counts in the open panel's header: the list below already does", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.focus(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
await screen.findByTestId("lane-git");
|
||||
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the rail's counts vertical", async () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and
|
||||
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||||
@@ -11,7 +13,8 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -61,7 +64,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
|
||||
const reset = async () => {
|
||||
@@ -78,67 +81,99 @@ export function AppearanceSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-5 space-y-5">
|
||||
<SettingsRow
|
||||
label={t("appearance.mode")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("appearance.mode")}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={MODES.map(({ mode: m, Icon }) => ({
|
||||
value: m,
|
||||
label: (
|
||||
<>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(`appearance.modes.${m}`)}
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
description={t("language.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("language.label")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: t("language.names.en") },
|
||||
{ value: "vi" as const, label: t("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{(["dark", "light"] as const).map((group) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t(`appearance.${group}Themes`)}
|
||||
{theme === group && (
|
||||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
label={t("appearance.mode")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("appearance.mode")}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={MODES.map(({ mode: m, Icon }) => ({
|
||||
value: m,
|
||||
label: (
|
||||
<>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(`appearance.modes.${m}`)}
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{(["dark", "light"] as const).map((group) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t(`appearance.${group}Themes`)}
|
||||
{theme === group && (
|
||||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
|
||||
<div className="flex justify-end border-t border-border pt-3">
|
||||
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("appearance.reset")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-border pt-3">
|
||||
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("appearance.reset")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pet" className="space-y-3 scroll-mt-24">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.pet")}</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("appearance.petDescription")}</p>
|
||||
</div>
|
||||
<PetSettings />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file PetSettings — Settings → Pet: the mascot's own page. Show it or not,
|
||||
* @file PetSettings — the pet block of Settings → Appearance (`#pet`). Show it or not,
|
||||
* its speech bubbles, its size (with a live preview), and which pet it wears
|
||||
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
|
||||
* install on the first pick, never bundled), and your own (deletable) — plus
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18n from "../../../i18n";
|
||||
import { AppearanceSettings } from "../AppearanceSettings";
|
||||
|
||||
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
describe("AppearanceSettings", () => {
|
||||
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
|
||||
root.removeAttribute("data-mode");
|
||||
});
|
||||
|
||||
it("switches the interface language and holds the pet block", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
expect(screen.getByTestId("pet-settings").closest("section")).toHaveAttribute("id", "pet");
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||
expect(i18n.language).toBe("vi");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders every theme of both modes, defaults selected", () => {
|
||||
render(<AppearanceSettings />);
|
||||
const pressed = screen
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
"justNow": "just now",
|
||||
"mAgo": "{{count}}m ago",
|
||||
"hAgo": "{{count}}h ago",
|
||||
"dAgo": "{{count}}d ago"
|
||||
"dAgo": "{{count}}d ago",
|
||||
"justNowShort": "now",
|
||||
"mAgoShort": "{{count}}m",
|
||||
"hAgoShort": "{{count}}h",
|
||||
"dAgoShort": "{{count}}d"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous",
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
"changes.collapseFile": "Collapse {{path}}",
|
||||
"changes.commitPlaceholder": "Commit message",
|
||||
"changes.commitStaged": "Commit {{count}} staged",
|
||||
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
|
||||
"changes.neverStage": "Never stage — Stage all skips this file",
|
||||
"changes.allowStage": "Include in Stage all again",
|
||||
"changes.keptHint": "Never staged by Stage all (this browser)",
|
||||
"changes.conflicted": "conflict",
|
||||
"changes.discard": "Discard this file's changes",
|
||||
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
||||
@@ -82,7 +86,7 @@
|
||||
"changes.loading": "Reading the working copy…",
|
||||
"changes.mode.browse": "Files",
|
||||
"changes.mode.history": "History",
|
||||
"changes.mode.overview": "Overview",
|
||||
"changes.mode.overview": "Changes",
|
||||
"changes.mode.review": "Review",
|
||||
"changes.noCommits": "No commits yet.",
|
||||
"changes.noHunks": "No textual change.",
|
||||
@@ -98,6 +102,10 @@
|
||||
"changes.size.compact": "Narrow",
|
||||
"changes.size.half": "Medium",
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.fileCount_one": "{{count}} file",
|
||||
"changes.fileCount_other": "{{count}} files",
|
||||
"changes.stageFolder": "Stage every file in this folder",
|
||||
"changes.unstageFolder": "Unstage every file in this folder",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
@@ -108,6 +116,7 @@
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
"changes.unavailable": "This workspace's directory is not a readable git repository.",
|
||||
"changes.unstage": "Unstage this file",
|
||||
"changes.changesView": "Changes",
|
||||
"changes.wholeFile": "Whole file",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
@@ -234,8 +243,8 @@
|
||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.revertBlock": "Revert",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.clickToEdit": "Click the text to edit",
|
||||
"changes.preview": "Preview",
|
||||
"changes.raw": "Raw",
|
||||
"changes.editorLabel": "Edit {{path}}",
|
||||
@@ -299,7 +308,6 @@
|
||||
"menu.copyPath": "Copy path",
|
||||
"menu.reset": "Reset worktree…",
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.files": "Files",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
@@ -323,6 +331,8 @@
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
|
||||
"tree.reopenTerminal": "Open a new terminal",
|
||||
"tree.forgetTerminal": "Remove from list",
|
||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
|
||||
@@ -67,7 +67,20 @@
|
||||
"today": "Today {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}",
|
||||
"tokens": "({{tokens}} Tokens)"
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "{{pct}}% used",
|
||||
"quotaTitle": "Usage",
|
||||
"quotaResets": "Resets in {{time}}",
|
||||
"quotaAsOf": "as of {{ago}}",
|
||||
"quotaRefresh": "Refresh",
|
||||
"quotaRefreshing": "Refreshing…",
|
||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaActive": "in use",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
"quotaWindow": { "wk": "Week", "mo": "Month" }
|
||||
},
|
||||
"version": "Version",
|
||||
"openMenu": "Open menu",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"import": "Import .codex-pet bundle…",
|
||||
"browse": "Find more on codex-pets.net",
|
||||
"settings": "Pet settings…",
|
||||
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.",
|
||||
"hiddenToast": "Pet hidden. Show it again in Settings → Appearance.",
|
||||
"deleteTitle": "Delete {{name}}?",
|
||||
"deleteMessage": "The pet's files are removed from this install. A featured pet can be downloaded again; an upload is gone for good.",
|
||||
"deleteConfirm": "Delete pet",
|
||||
@@ -587,6 +587,8 @@
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"fieldBehavesAs": "Behaves as (Claude Code)",
|
||||
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
@@ -651,7 +653,10 @@
|
||||
"reset": "Reset to defaults",
|
||||
"resetTitle": "Reset appearance?",
|
||||
"resetBody": "Mode goes back to Dark and both modes to their default theme.",
|
||||
"resetDone": "Appearance reset."
|
||||
"resetDone": "Appearance reset.",
|
||||
"theme": "Theme",
|
||||
"pet": "Pet",
|
||||
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -751,17 +756,9 @@
|
||||
"cost": "Cost"
|
||||
},
|
||||
"section": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "The dashboard's display language."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
"description": "Language, colour mode and theme, and the corner pet."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
"justNow": "vừa xong",
|
||||
"mAgo": "{{count}} phút trước",
|
||||
"hAgo": "{{count}} giờ trước",
|
||||
"dAgo": "{{count}} ngày trước"
|
||||
"dAgo": "{{count}} ngày trước",
|
||||
"justNowShort": "vừa xong",
|
||||
"mAgoShort": "{{count}} phút",
|
||||
"hAgoShort": "{{count}} giờ",
|
||||
"dAgoShort": "{{count}} ngày"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Trước",
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
"changes.collapseFile": "Thu gọn {{path}}",
|
||||
"changes.commitPlaceholder": "Nội dung commit",
|
||||
"changes.commitStaged": "Commit {{count}} file đã stage",
|
||||
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
|
||||
"changes.neverStage": "Không bao giờ stage — Stage all bỏ qua file này",
|
||||
"changes.allowStage": "Cho Stage all stage lại file này",
|
||||
"changes.keptHint": "Stage all không bao giờ stage file này (trên trình duyệt này)",
|
||||
"changes.conflicted": "xung đột",
|
||||
"changes.discard": "Bỏ thay đổi của file này",
|
||||
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
|
||||
@@ -82,7 +86,7 @@
|
||||
"changes.loading": "Đang đọc working copy…",
|
||||
"changes.mode.browse": "Tệp",
|
||||
"changes.mode.history": "Lịch sử",
|
||||
"changes.mode.overview": "Tổng quan",
|
||||
"changes.mode.overview": "Thay đổi",
|
||||
"changes.mode.review": "Xem toàn bộ",
|
||||
"changes.noCommits": "Chưa có commit nào.",
|
||||
"changes.noHunks": "Không có thay đổi nội dung.",
|
||||
@@ -98,6 +102,10 @@
|
||||
"changes.size.compact": "Hẹp",
|
||||
"changes.size.half": "Vừa",
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.fileCount_one": "{{count}} tệp",
|
||||
"changes.fileCount_other": "{{count}} tệp",
|
||||
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
|
||||
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
@@ -108,6 +116,7 @@
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
|
||||
"changes.unstage": "Bỏ file này khỏi index",
|
||||
"changes.changesView": "Thay đổi",
|
||||
"changes.wholeFile": "Toàn bộ file",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
@@ -234,8 +243,8 @@
|
||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.revertBlock": "Hoàn tác",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.clickToEdit": "Bấm vào nội dung để sửa",
|
||||
"changes.preview": "Xem trước",
|
||||
"changes.raw": "Mã nguồn",
|
||||
"changes.editorLabel": "Sửa {{path}}",
|
||||
@@ -299,7 +308,6 @@
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
"menu.reset": "Đặt lại worktree…",
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.files": "Tệp",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
@@ -323,6 +331,8 @@
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
|
||||
"tree.reopenTerminal": "Mở terminal mới",
|
||||
"tree.forgetTerminal": "Xóa khỏi danh sách",
|
||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
|
||||
@@ -67,7 +67,20 @@
|
||||
"today": "Hôm nay {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}",
|
||||
"tokens": "({{tokens}} Tokens)"
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "đã dùng {{pct}}%",
|
||||
"quotaTitle": "Mức dùng",
|
||||
"quotaResets": "Đặt lại sau {{time}}",
|
||||
"quotaAsOf": "lúc {{ago}}",
|
||||
"quotaRefresh": "Làm mới",
|
||||
"quotaRefreshing": "Đang làm mới…",
|
||||
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
|
||||
"quotaFailed": "Không đọc được quota",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||
"quotaWindow": { "wk": "Tuần", "mo": "Tháng" }
|
||||
},
|
||||
"version": "Phiên bản",
|
||||
"openMenu": "Mở menu",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"import": "Nhập bundle .codex-pet…",
|
||||
"browse": "Tìm thêm trên codex-pets.net",
|
||||
"settings": "Cài đặt pet…",
|
||||
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Pet.",
|
||||
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Giao diện.",
|
||||
"deleteTitle": "Xoá {{name}}?",
|
||||
"deleteMessage": "Các tệp của pet sẽ bị xoá khỏi bản cài này. Pet nổi bật có thể tải lại; ảnh bạn tải lên sẽ mất hẳn.",
|
||||
"deleteConfirm": "Xoá pet",
|
||||
@@ -587,6 +587,8 @@
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"fieldBehavesAs": "Hoạt động như (Claude Code)",
|
||||
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
@@ -651,7 +653,10 @@
|
||||
"reset": "Khôi phục mặc định",
|
||||
"resetTitle": "Khôi phục giao diện?",
|
||||
"resetBody": "Chế độ trở về Tối và cả hai chế độ trở về giao diện mặc định.",
|
||||
"resetDone": "Đã khôi phục giao diện."
|
||||
"resetDone": "Đã khôi phục giao diện.",
|
||||
"theme": "Theme",
|
||||
"pet": "Pet",
|
||||
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -751,17 +756,9 @@
|
||||
"cost": "Chi phí"
|
||||
},
|
||||
"section": {
|
||||
"general": {
|
||||
"title": "Chung",
|
||||
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Thông báo",
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
--syn-plain: var(--fg-primary);
|
||||
--syn-add: 4 120 87; /* emerald-700 */
|
||||
--syn-del: 185 28 28; /* red-700 #b91c1c */
|
||||
/* A diff line edited in place (not wholly added or removed). */
|
||||
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
|
||||
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
|
||||
--kept: 194 65 12; /* orange-700 #c2410c */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -154,6 +158,8 @@
|
||||
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
|
||||
--syn-add: 110 231 183; /* emerald-300 */
|
||||
--syn-del: 252 165 165; /* red-300 #fca5a5 */
|
||||
--diff-mod: 96 165 250; /* blue-400 #60a5fa */
|
||||
--kept: 251 146 60; /* orange-400 #fb923c */
|
||||
}
|
||||
|
||||
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's folder tree: grouping by folder,
|
||||
* compacting single-child chains, counts, ordering and the per-folder file list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { changeTree, treeFiles } from "../changeTree";
|
||||
|
||||
const rows = [
|
||||
"app2/Services/TwitterService.php",
|
||||
"app2/Services/Redis/SlowAlertRedisService.php",
|
||||
"app2/Enums/Redis/RedisSlowAlertKey.php",
|
||||
"config/monitoring.php",
|
||||
"config/app.php",
|
||||
"README.md",
|
||||
].map((path) => ({ path }));
|
||||
|
||||
describe("changeTree", () => {
|
||||
const root = changeTree(rows);
|
||||
|
||||
it("puts root files at the top level and counts every file", () => {
|
||||
expect(root.count).toBe(6);
|
||||
expect(root.files.map((f) => f.name)).toEqual(["README.md"]);
|
||||
expect(root.dirs.map((d) => d.name)).toEqual(["app2", "config"]);
|
||||
});
|
||||
|
||||
it("compacts a folder chain with nothing else in it", () => {
|
||||
const app2 = root.dirs[0]!;
|
||||
expect(app2.count).toBe(3);
|
||||
expect(app2.dirs.map((d) => [d.name, d.path])).toEqual([
|
||||
["Enums/Redis", "app2/Enums/Redis"],
|
||||
["Services", "app2/Services"],
|
||||
]);
|
||||
// Services holds a file, so it is not folded into Redis.
|
||||
expect(app2.dirs[1]!.dirs[0]!.name).toBe("Redis");
|
||||
});
|
||||
|
||||
it("sorts files by name and lists a folder's files", () => {
|
||||
expect(root.dirs[1]!.files.map((f) => f.name)).toEqual(["app.php", "monitoring.php"]);
|
||||
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeTree — an untracked folder", () => {
|
||||
it("shows git's `dir/` entry as a named row in its parent folder", () => {
|
||||
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
|
||||
const docs = root.dirs[0]!;
|
||||
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
|
||||
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file Tests for the History commit-graph layout: a straight line, a branch
|
||||
* merged back, converging lanes and a parent beyond the loaded page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { commitGraph } from "../commitGraph";
|
||||
|
||||
const c = (sha: string, ...parents: string[]) => ({ sha, parents });
|
||||
|
||||
describe("commitGraph", () => {
|
||||
it("draws a linear history in one column", () => {
|
||||
const { rows, width } = commitGraph([c("c", "b"), c("b", "a"), c("a")]);
|
||||
expect(width).toBe(1);
|
||||
expect(rows.map((r) => r.col)).toEqual([0, 0, 0]);
|
||||
// The tip has no line from above; the root none below.
|
||||
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
|
||||
expect(rows[2]?.segs).toEqual([{ x1: 0, y1: 0, x2: 0, y2: 0.5, color: 0 }]);
|
||||
});
|
||||
|
||||
it("opens a lane for a merged branch and closes it at the fork point", () => {
|
||||
// m merge of b2 into a2
|
||||
// |\\
|
||||
// a2 b2
|
||||
// | b1
|
||||
// |/
|
||||
// a1
|
||||
const { rows, width } = commitGraph([
|
||||
c("m", "a2", "b2"),
|
||||
c("a2", "a1"),
|
||||
c("b2", "b1"),
|
||||
c("b1", "a1"),
|
||||
c("a1"),
|
||||
]);
|
||||
expect(width).toBe(2);
|
||||
expect(rows.map((r) => r.col)).toEqual([0, 0, 1, 1, 0]);
|
||||
expect(rows[0]?.segs).toContainEqual({ x1: 0, y1: 0.5, x2: 1, y2: 1, color: 1 });
|
||||
// b1's lane converges into a1's column.
|
||||
expect(rows[4]?.segs).toContainEqual({ x1: 1, y1: 0, x2: 0, y2: 0.5, color: 1 });
|
||||
expect(rows[1]?.color).toBe(rows[0]?.color);
|
||||
expect(rows[2]?.color).not.toBe(rows[0]?.color);
|
||||
});
|
||||
|
||||
it("keeps a lane running for a parent past the page", () => {
|
||||
const { rows } = commitGraph([c("b", "a")]);
|
||||
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -390,6 +390,7 @@ import type {
|
||||
CodexHooksStatus,
|
||||
AntigravityHooksStatus,
|
||||
CostResult,
|
||||
ToolQuota,
|
||||
DashboardEvent,
|
||||
Lane,
|
||||
LaneChangeRange,
|
||||
@@ -1392,6 +1393,11 @@ export const api = {
|
||||
applyScope(qs);
|
||||
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
|
||||
},
|
||||
/** GET /api/pricing/quota - plan quota spent per native tool; `force` for a Refresh. */
|
||||
quota: (force = false) =>
|
||||
request<{ tools: ToolQuota[]; checkedAt: string }>(
|
||||
`/pricing/quota${force ? "?force=1" : ""}`
|
||||
),
|
||||
/**
|
||||
* GET /api/pricing/cost/:sessionId - cost for one session, priced as of
|
||||
* the session's start date.
|
||||
@@ -2186,6 +2192,9 @@ export const api = {
|
||||
if (opts.limit) qs.set("limit", String(opts.limit));
|
||||
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
|
||||
},
|
||||
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
|
||||
forgetHistory: (id: string) =>
|
||||
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||
binary: (tool: LaneTool = "claude") =>
|
||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||
@@ -2757,6 +2766,12 @@ export const api = {
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(gitUrl(id, "/branches")),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
|
||||
* written by the user's `claude` CLI (no tools, nothing persisted).
|
||||
*/
|
||||
suggestCommitMessage: (id: GitTarget) =>
|
||||
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
@@ -3286,6 +3301,8 @@ export interface AiRouter {
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
||||
behavesAs?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
@@ -3318,6 +3335,8 @@ export interface AiRouterInput {
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Folds the Changes panel's flat list of changed files into a folder
|
||||
* tree, JetBrains' commit-view shape: folders first, then files, each sorted
|
||||
* by name, and a chain of folders that hold nothing but one sub-folder drawn
|
||||
* as ONE row (`src/lib`), so a deep path does not cost a row per level.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export interface ChangeTreeDir<T> {
|
||||
/** The row's label — one segment, or several joined by `/` when compacted. */
|
||||
name: string;
|
||||
/** The folder's repo-relative path ("" for the root). */
|
||||
path: string;
|
||||
dirs: ChangeTreeDir<T>[];
|
||||
files: { name: string; file: T }[];
|
||||
/** Every file anywhere below. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
||||
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
||||
for (const file of rows) {
|
||||
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
|
||||
const parts = file.path.replace(/\/$/, "").split("/");
|
||||
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
|
||||
let dir = root;
|
||||
dir.count++;
|
||||
for (const seg of parts.slice(0, -1)) {
|
||||
const path = dir.path ? `${dir.path}/${seg}` : seg;
|
||||
let next = dir.dirs.find((d) => d.path === path);
|
||||
if (!next) dir.dirs.push((next = { name: seg, path, dirs: [], files: [], count: 0 }));
|
||||
next.count++;
|
||||
dir = next;
|
||||
}
|
||||
dir.files.push({ name: parts[parts.length - 1] ?? file.path, file });
|
||||
}
|
||||
const finish = (d: ChangeTreeDir<T>): ChangeTreeDir<T> => {
|
||||
// Never the root: its children are the group's top level.
|
||||
let only: ChangeTreeDir<T> | undefined;
|
||||
while (d.path && d.files.length === 0 && d.dirs.length === 1 && (only = d.dirs[0])) {
|
||||
d = { ...only, name: `${d.name}/${only.name}` };
|
||||
}
|
||||
d.dirs = d.dirs.map(finish).sort((a, b) => a.name.localeCompare(b.name));
|
||||
d.files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return d;
|
||||
};
|
||||
return finish(root);
|
||||
}
|
||||
|
||||
/** Every file under a folder, for its stage/unstage-all button. */
|
||||
export function treeFiles<T>(d: ChangeTreeDir<T>): T[] {
|
||||
return [...d.files.map((f) => f.file), ...d.dirs.flatMap(treeFiles)];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -337,23 +337,26 @@ export function formatMs(ms: number): string {
|
||||
* Formats how long ago an ISO/SQLite timestamp was, as a translated relative
|
||||
* string ("just now", "5m ago", "3h ago", "2d ago") using {@link i18n}.
|
||||
* @param iso A past timestamp (see {@link parseDate}).
|
||||
* @param short Drop the "ago" ("now", "5m") — the `common:time.*Short` keys.
|
||||
* @returns A localized relative-time phrase; i18next handles pluralization via `count`.
|
||||
* @remarks Thresholds cascade seconds -> minutes -> hours -> days (days is the largest
|
||||
* bucket, so a 40-day-old event reads "40d ago"). Under a minute collapses to the
|
||||
* "just now" key. Uses translated keys, not `Intl.RelativeTimeFormat`, so the exact
|
||||
* wording is controlled by the `common:time.*` translation resources.
|
||||
*/
|
||||
export function timeAgo(iso: string): string {
|
||||
export function timeAgo(iso: string, short = false): string {
|
||||
const ms = Date.now() - parseDate(iso).getTime();
|
||||
if (Number.isNaN(ms)) return "-";
|
||||
// `short` drops the "ago" ("17m"), for a narrow column like the sidebar's.
|
||||
const k = short ? "Short" : "";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return i18n.t("common:time.justNow");
|
||||
if (seconds < 60) return i18n.t(`common:time.justNow${k}`);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return i18n.t("common:time.mAgo", { count: minutes });
|
||||
if (minutes < 60) return i18n.t(`common:time.mAgo${k}`, { count: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return i18n.t("common:time.hAgo", { count: hours });
|
||||
if (hours < 24) return i18n.t(`common:time.hAgo${k}`, { count: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
return i18n.t("common:time.dAgo", { count: days });
|
||||
return i18n.t(`common:time.dAgo${k}`, { count: days });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* @description Keyboard helpers shared by every single-line submit field.
|
||||
* `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese
|
||||
* Telex, CJK): without it a rename or commit message is committed half-typed.
|
||||
* `copyOnCtrlShiftC` makes Ctrl+Shift+C a copy, as in a Linux terminal, instead
|
||||
* of the browser's inspect-element shortcut (App.tsx installs it, capture phase).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -13,3 +15,14 @@ export function isSubmitEnter(e: ReactKeyboardEvent | KeyboardEvent): boolean {
|
||||
const native = "nativeEvent" in e ? e.nativeEvent : e;
|
||||
return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+Shift+C copies the selection — a field's, the page's or a terminal's
|
||||
* (xterm answers the `copy` event) — instead of opening DevTools. F12 and
|
||||
* Ctrl+Shift+I still open them. With nothing selected it does nothing.
|
||||
*/
|
||||
export function copyOnCtrlShiftC(e: KeyboardEvent): void {
|
||||
if (e.code !== "KeyC" || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
document.execCommand("copy");
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ export const SETTINGS_INDEX: {
|
||||
to?: string;
|
||||
}[] = [
|
||||
{
|
||||
section: "general",
|
||||
section: "appearance",
|
||||
titleKey: "language.title",
|
||||
to: "#language",
|
||||
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||
},
|
||||
{
|
||||
@@ -90,14 +91,20 @@ export const SETTINGS_INDEX: {
|
||||
keywords: ["version", "server", "database", "uptime"],
|
||||
},
|
||||
{
|
||||
section: "pet",
|
||||
section: "appearance",
|
||||
titleKey: "sagi.title",
|
||||
descKey: "sagi.description",
|
||||
keywords: ["sagi", "mascot", "pet"],
|
||||
to: "#pet",
|
||||
},
|
||||
{ section: "appearance", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc", to: "#pet" },
|
||||
{ section: "appearance", titleKey: "sagi.pet.size", to: "#pet" },
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "sagi.pet.choose",
|
||||
keywords: ["codex-pets", "upload", "import"],
|
||||
to: "#pet",
|
||||
},
|
||||
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
|
||||
{ section: "pet", titleKey: "sagi.pet.size" },
|
||||
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
|
||||
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
|
||||
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
|
||||
{ section: "appearance", titleKey: "appearance.lightThemes" },
|
||||
|
||||
@@ -1206,6 +1206,25 @@ export interface CostResult {
|
||||
unpriced_models?: UnpricedModel[];
|
||||
}
|
||||
|
||||
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
|
||||
export interface ToolQuota {
|
||||
id: "claude" | "codex";
|
||||
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100. */
|
||||
windows: Array<{ label: string; usedPercent: number | null; resetsAt: string | null }>;
|
||||
/** Why there is no figure, e.g. `ELIVEEXPIRED` (Claude's token needs a CLI refresh). */
|
||||
error?: string;
|
||||
/** False for a saved Claude auth profile that is not the live login. */
|
||||
active?: boolean;
|
||||
/** The saved profile's label, when the account has one. */
|
||||
label?: string | null;
|
||||
/** The signed-in account's email, when the tool's login names one. */
|
||||
account?: string | null;
|
||||
/** When the stored access token expires (ISO). */
|
||||
expiresAt?: string | null;
|
||||
/** When the figure was read, if not just now (Codex's journal, an expired profile). */
|
||||
asOf?: string;
|
||||
}
|
||||
|
||||
// ───── Import progress ─────
|
||||
|
||||
/** Payload of the `import.progress` WebSocket message, streamed while a
|
||||
@@ -2716,6 +2735,8 @@ export interface LaneCommit {
|
||||
date: string;
|
||||
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
|
||||
refs: string[];
|
||||
/** Parent shas, first parent first — the history's commit graph. */
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
|
||||
@@ -2795,6 +2816,8 @@ export type LaneChanges =
|
||||
base: string | null;
|
||||
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
|
||||
baseRef: string | null;
|
||||
/** `range: "branch"`: the merge base the diff is taken from (full sha). */
|
||||
mergeBase?: string | null;
|
||||
/** The commit being shown, for `range: "commit"`; its full sha. */
|
||||
sha: string | null;
|
||||
/** Lines of unchanged context each hunk carries. */
|
||||
|
||||
@@ -39,8 +39,9 @@ import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
|
||||
apiKey: string;
|
||||
behavesAs: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
@@ -50,6 +51,7 @@ const emptyForm = (): FormState => ({
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
@@ -121,6 +123,7 @@ export function AiRouterConfig() {
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
behavesAs: item.behavesAs ?? "",
|
||||
});
|
||||
setFormError(null);
|
||||
setOpen(true);
|
||||
@@ -175,6 +178,7 @@ export function AiRouterConfig() {
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
behavesAs: form.behavesAs.trim(),
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
@@ -370,6 +374,15 @@ export function AiRouterConfig() {
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-behaves-as"
|
||||
label={t("aiRouter.fieldBehavesAs")}
|
||||
hint={t("aiRouter.fieldBehavesAsHint")}
|
||||
value={form.behavesAs}
|
||||
mono
|
||||
placeholder="claude-opus-5-5"
|
||||
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as four pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — language —, `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s (Sagi has its own page, `components/settings/PetSettings.tsx`). Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -205,12 +205,12 @@ function useCountUp(end: number | null, durationMs = 1000) {
|
||||
// ─── Main component ───
|
||||
|
||||
export function Settings({
|
||||
section = "general",
|
||||
section = "notifications",
|
||||
}: {
|
||||
/** Which Settings page this renders (`/settings/<section>`). */
|
||||
section?: "general" | "data" | "about" | "notifications";
|
||||
section?: "data" | "about" | "notifications";
|
||||
} = {}) {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { t } = useTranslation("settings");
|
||||
// Only Data and About show server info; General and Notifications never read it.
|
||||
const needsInfo = section === "data" || section === "about";
|
||||
const [loading, setLoading] = useState(needsInfo);
|
||||
@@ -541,27 +541,6 @@ export function Settings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "general" && (
|
||||
<>
|
||||
<SettingsSection title={t("language.title")} description={t("language.description")}>
|
||||
<SettingsRow
|
||||
label={t("language.label")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: t("language.names.en") },
|
||||
{ value: "vi" as const, label: t("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "notifications" && (
|
||||
<>
|
||||
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* element with that `id` or `data-settings-anchor` and briefly highlights it.
|
||||
* Back returns to the page before Settings when there is one, else /workspace;
|
||||
* an unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* and falls back to General. A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* and falls back to Appearance (which also holds the language and the pet;
|
||||
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* Codex have their own pages, every other installed, enabled catalog tool the
|
||||
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
|
||||
* disabled. A workspace's own settings live on its home, not here.
|
||||
@@ -32,18 +33,12 @@ import {
|
||||
Database,
|
||||
Info,
|
||||
Bot,
|
||||
Boxes,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Orbit,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Puzzle,
|
||||
Router,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
@@ -52,7 +47,6 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { PetSettings } from "../components/settings/PetSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -64,6 +58,7 @@ import {
|
||||
type SettingsEntry,
|
||||
} from "../lib/settingsSearch";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
|
||||
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
|
||||
@@ -80,7 +75,7 @@ interface Section {
|
||||
|
||||
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
|
||||
const LEGACY_TOOL: Record<string, string> = {
|
||||
dashboard: "general",
|
||||
dashboard: "appearance",
|
||||
"claude-code": "agents/claude-code",
|
||||
codex: "agents/codex",
|
||||
"ai-router": "ai-router",
|
||||
@@ -104,13 +99,29 @@ const historyIdx = (): number => {
|
||||
export const backDelta = (current: number, entry: number): number =>
|
||||
-Math.max(1, current - entry + 1);
|
||||
|
||||
/** An unknown section: say so, then show General. */
|
||||
/** Sections folded into Appearance: old links and bookmarks still land. */
|
||||
/** A tool's own mark as a nav icon, one stable component per tool id. */
|
||||
const navIcons = new Map<string, ComponentType<{ className?: string }>>();
|
||||
function toolNavIcon(id: string, label: string) {
|
||||
let Icon = navIcons.get(id);
|
||||
if (!Icon) {
|
||||
Icon = ({ className }) => (
|
||||
<AiToolIcon tool={{ id, label }} className={`${className ?? ""} !bg-transparent !p-0`} />
|
||||
);
|
||||
navIcons.set(id, Icon);
|
||||
}
|
||||
return Icon;
|
||||
}
|
||||
|
||||
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
useEffect(() => {
|
||||
toast.info(t("settingsNav.unknownSection", { section: name }));
|
||||
}, [name, t]);
|
||||
return <Navigate to="/settings/general" replace />;
|
||||
return <Navigate to="/settings/appearance" replace />;
|
||||
}
|
||||
|
||||
export function SettingsHub() {
|
||||
@@ -191,13 +202,6 @@ export function SettingsHub() {
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: "general",
|
||||
group: "general",
|
||||
Icon: SettingsIcon,
|
||||
...s("general"),
|
||||
render: () => <Settings section="general" />,
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
group: "general",
|
||||
@@ -219,13 +223,6 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "pet",
|
||||
group: "general",
|
||||
Icon: PawPrint,
|
||||
...s("pet"),
|
||||
render: () => <PetSettings />,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
@@ -245,7 +242,7 @@ export function SettingsHub() {
|
||||
{
|
||||
id: "agents/claude-code",
|
||||
group: "agents" as const,
|
||||
Icon: Boxes,
|
||||
Icon: toolNavIcon("claude", "Claude Code"),
|
||||
...s("claudeCode"),
|
||||
render: () => <CcConfig />,
|
||||
},
|
||||
@@ -256,7 +253,7 @@ export function SettingsHub() {
|
||||
{
|
||||
id: "agents/codex",
|
||||
group: "agents" as const,
|
||||
Icon: SquareTerminal,
|
||||
Icon: toolNavIcon("codex", "Codex"),
|
||||
...s("codex"),
|
||||
render: () => <CodexConfig />,
|
||||
},
|
||||
@@ -269,7 +266,7 @@ export function SettingsHub() {
|
||||
.map((x) => ({
|
||||
id: `agents/${x.id}`,
|
||||
group: "agents" as const,
|
||||
Icon: x.id === "antigravity" ? Orbit : Puzzle,
|
||||
Icon: toolNavIcon(x.id, x.label),
|
||||
title: x.label,
|
||||
description: t("settingsNav.section.tool.description", { tool: x.label }),
|
||||
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
|
||||
@@ -349,9 +346,10 @@ export function SettingsHub() {
|
||||
? "agents"
|
||||
: tool
|
||||
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
||||
: "general";
|
||||
: "appearance";
|
||||
return <Navigate to={`/settings/${to}`} replace />;
|
||||
}
|
||||
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
|
||||
const active = sections.find((x) => x.id === rest);
|
||||
if (!active) {
|
||||
// A catalog tool page exists only once the tool list has loaded.
|
||||
|
||||
@@ -61,8 +61,6 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -120,17 +118,9 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the right panel's activity-bar tab. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
const PANEL_TABS = [
|
||||
{ id: "files", Icon: FolderTree },
|
||||
{ id: "changes", Icon: GitCompare },
|
||||
] as const;
|
||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
@@ -600,21 +590,6 @@ export function Workspace() {
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||||
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* the panel just forgets its tab between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||
// localStorage and never reaches the server — same rule as the lane strip's
|
||||
// order (client/src/lib/laneOrderStorage.ts).
|
||||
@@ -1231,36 +1206,17 @@ export function Workspace() {
|
||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||
>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||||
control — the same height as the console's tab bar, so the
|
||||
two read as one header strip. */}
|
||||
{/* Header: the title, Analyst, then the resize control — the
|
||||
same height as the console's tab bar, so the two read as
|
||||
one header strip. Files and Changes are ONE panel now; its
|
||||
own mode switcher moves between them. */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-testid={`panel-tab-${id}`}
|
||||
aria-pressed={panelTab === id}
|
||||
// Narrow, a tab switch leaves the open file (and its
|
||||
// edit); at full width the editor stays put.
|
||||
onClick={() =>
|
||||
panelTab !== id &&
|
||||
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
|
||||
}
|
||||
title={tLanes(`panel.${id}`)}
|
||||
aria-label={tLanes(`panel.${id}`)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
panelTab === id
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1318,8 +1274,8 @@ export function Workspace() {
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
tab={panelTab}
|
||||
full={changesFull}
|
||||
wide={changesPreset !== "compact"}
|
||||
onDirtyChange={setChangesDirty}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
@@ -1355,7 +1311,13 @@ export function Workspace() {
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
|
||||
{panelAt && (
|
||||
<LaneGitRailCounts
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -104,11 +104,30 @@ describe("AiRouterConfig", () => {
|
||||
// Every tier equals the headline model, so none is sent: the server
|
||||
// fills them, which is what makes one field enough.
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the tiers that differ from the headline model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -66,12 +66,8 @@ const burst = () => {
|
||||
};
|
||||
|
||||
describe("Settings", () => {
|
||||
it("never reads server info on Notifications or General, even on activity", async () => {
|
||||
const { unmount } = render(<Settings section="notifications" />);
|
||||
burst();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
unmount();
|
||||
render(<Settings section="general" />);
|
||||
it("never reads server info on Notifications, even on activity", async () => {
|
||||
render(<Settings section="notifications" />);
|
||||
burst();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock("../Settings", () => ({
|
||||
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../../components/settings/PetSettings", () => ({
|
||||
PetSettings: () => <div data-testid="pet-settings" />,
|
||||
}));
|
||||
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
|
||||
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
|
||||
vi.mock("../AiToolConfig", () => ({
|
||||
@@ -114,7 +117,9 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/settings", "/settings/general"],
|
||||
["/settings", "/settings/appearance"],
|
||||
["/settings/general", "/settings/appearance"],
|
||||
["/settings/pet", "/settings/appearance"],
|
||||
["/settings?tool=codex", "/settings/agents/codex"],
|
||||
["/settings?tool=claude-code", "/settings/agents/claude-code"],
|
||||
["/settings?tool=gemini", "/settings/agents/gemini"],
|
||||
@@ -125,10 +130,12 @@ describe("SettingsHub", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe(to);
|
||||
});
|
||||
|
||||
it("sends an unknown section to General once the lists have loaded", async () => {
|
||||
it("sends an unknown section to Appearance once the lists have loaded", async () => {
|
||||
renderAt("/settings/nope");
|
||||
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/appearance");
|
||||
});
|
||||
|
||||
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
|
||||
@@ -145,8 +152,8 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it("navigates from the mobile select", async () => {
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
|
||||
target: { value: "pricing" },
|
||||
});
|
||||
@@ -156,8 +163,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
await user.type(box, "pricing");
|
||||
const links = within(nav())
|
||||
@@ -177,8 +184,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
expect(box).toHaveFocus();
|
||||
@@ -187,8 +194,8 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
@@ -204,8 +211,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
|
||||
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
|
||||
await user.type(box, "pet");
|
||||
@@ -217,8 +224,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
|
||||
const list = screen.getByRole("list", { name: "Search results" });
|
||||
await user.click(within(list).getAllByRole("link")[0]!);
|
||||
@@ -227,14 +234,16 @@ describe("SettingsHub", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
|
||||
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
|
||||
renderAt("/settings/data");
|
||||
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
|
||||
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
|
||||
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
|
||||
expect(links).not.toContain("General");
|
||||
expect(links).not.toContain("Pet");
|
||||
});
|
||||
|
||||
it("scrolls to and highlights the anchor the URL names", async () => {
|
||||
@@ -258,11 +267,11 @@ describe("SettingsHub", () => {
|
||||
expect(backDelta(0, 2)).toBe(-1);
|
||||
});
|
||||
|
||||
it("toasts when an unknown section falls back to General", async () => {
|
||||
it("toasts when an unknown section falls back to Appearance", async () => {
|
||||
let seen: ToastItem[] = [];
|
||||
const off = subscribeToasts((i) => (seen = i));
|
||||
renderAt("/settings/nope");
|
||||
await screen.findByTestId("settings-general");
|
||||
await screen.findByTestId("pet-settings");
|
||||
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
@@ -6832,74 +6832,6 @@ exports[`screen snapshots > Sessions 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`screen snapshots > Settings 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="animate-fade-in space-y-8"
|
||||
>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Language
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
The language of the whole dashboard, remembered in this browser.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Interface language
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<div
|
||||
aria-label="Interface language"
|
||||
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
|
||||
role="radio"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
role="radio"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
Tiếng Việt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`screen snapshots > Settings — about 1`] = `
|
||||
<div>
|
||||
<div
|
||||
@@ -6981,7 +6913,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.1.0 (rebuild client to match)
|
||||
UI build v2.2.1 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, act, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
@@ -593,13 +593,9 @@ describe("screen snapshots", () => {
|
||||
it("Run", async () => {
|
||||
await snapshot(<Workspace />, "/run");
|
||||
});
|
||||
it("Settings", async () => {
|
||||
await snapshot(<Settings />, "/settings");
|
||||
});
|
||||
it("Settings — notifications", async () => {
|
||||
await snapshot(<Settings section="notifications" />, "/settings/notifications");
|
||||
});
|
||||
// General split into General / Data / About: keep the moved content covered.
|
||||
it("Settings — data", async () => {
|
||||
await snapshot(<Settings section="data" />, "/settings/data");
|
||||
});
|
||||
@@ -611,29 +607,6 @@ describe("screen snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The language picker moved off the sidebar into Settings → General.
|
||||
describe("Settings — language", () => {
|
||||
it("switches the interface language from General", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings/general"]}>
|
||||
<Settings section="general" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||
await settle();
|
||||
expect(i18n.language).toBe("vi");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
|
||||
// other sub-tabs.
|
||||
|
||||
@@ -49,6 +49,8 @@ const tinted = {
|
||||
danger: tint("--status-danger"),
|
||||
warning: tint("--status-warning"),
|
||||
},
|
||||
diff: { mod: tint("--diff-mod") },
|
||||
kept: tint("--kept"),
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
@@ -104,6 +106,8 @@ export default {
|
||||
danger: "rgb(var(--status-danger) / <alpha-value>)",
|
||||
warning: "rgb(var(--status-warning) / <alpha-value>)",
|
||||
},
|
||||
diff: { mod: "rgb(var(--diff-mod) / <alpha-value>)" },
|
||||
kept: "rgb(var(--kept) / <alpha-value>)",
|
||||
},
|
||||
backgroundColor: tinted,
|
||||
borderColor: tinted,
|
||||
|
||||
+37
-6
@@ -491,7 +491,10 @@ One route, three depths — the shapes the Workspace's Changes panel asks for:
|
||||
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
|
||||
untracked files included, `branch` is `<base>...HEAD` for the workspace's
|
||||
`base_branch`, and `commit` shows the single commit named by `sha` (an
|
||||
unresolvable one is `400 EBADREV`, never an empty diff).
|
||||
unresolvable one is `400 EBADREV`, never an empty diff). A `branch` answer also
|
||||
carries `mergeBase` — the full sha of `git merge-base <baseRef> HEAD`, the
|
||||
commit the diff is taken from (`null` on the other ranges or when git cannot
|
||||
find one); the panel's editor reads a file's original text there.
|
||||
|
||||
`context` defaults to **3** and is clamped to **5000** — at the top of that
|
||||
range the diff has effectively become the whole file, which is what "expand
|
||||
@@ -509,6 +512,7 @@ that file.
|
||||
"range": "work",
|
||||
"base": "development",
|
||||
"baseRef": "origin/development",
|
||||
"mergeBase": null,
|
||||
"sha": null,
|
||||
"context": 3,
|
||||
"truncated": false,
|
||||
@@ -570,7 +574,8 @@ and file explorer. The GETs are read-only and unguarded, and the JSON ones
|
||||
answer `{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
|
||||
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
|
||||
refs, parents}]}`, newest first in `--date-order` (no parent before its
|
||||
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
|
||||
commits touching that path — relative, no `..` segment, else
|
||||
`400 EBADPATH`. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
@@ -614,7 +619,7 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
ignore (`ls-files --cached --others --exclude-standard`), directories folded
|
||||
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
|
||||
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the workspace.
|
||||
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
|
||||
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Changes panel's Files mode's **Expand all folders** is this one request instead of one per folder.
|
||||
|
||||
#### Read a workspace's own branches
|
||||
|
||||
@@ -655,6 +660,22 @@ reason is gone) is cleared first. A live pane still holding the session is
|
||||
`409 ELIVE`; no `confirm` is `400 ECONFIRM`; a session with no worktree here
|
||||
`400 ENOWORKTREE`. Answers `{removed: true, branch, branchKept}`.
|
||||
|
||||
#### Suggest a commit message
|
||||
|
||||
```http
|
||||
POST /api/lanes/:id/git/commit-message
|
||||
```
|
||||
|
||||
The Changes panel's ✨ button beside the commit box. Runs the user's own
|
||||
`claude` CLI — `claude -p --safe-mode --no-session-persistence --tools "" --model haiku`
|
||||
— on the **staged** diff of the workspace's cwd (or `?session=` worktree), sent on
|
||||
stdin together with the last ten commit subjects so the message follows the
|
||||
repo's style. Only reads: no tool, hook or plugin runs, no transcript is saved,
|
||||
and a `settings.local.json` is never part of the diff. Same-origin, because
|
||||
each call spends the user's Claude quota. Answers `{"message": "..."}`;
|
||||
`400 ENOSTAGED` with nothing staged, `400 ENOTOOL` without `claude` on PATH,
|
||||
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
|
||||
|
||||
#### Act on a workspace's working copy
|
||||
|
||||
```http
|
||||
@@ -1991,6 +2012,14 @@ The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]`
|
||||
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
|
||||
```
|
||||
|
||||
#### Get Plan Quota
|
||||
|
||||
```
|
||||
GET /api/pricing/quota[?force=1]
|
||||
```
|
||||
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
|
||||
|
||||
---
|
||||
|
||||
### Notifications
|
||||
@@ -2369,7 +2398,7 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
|
||||
|
||||
```
|
||||
GET /api/ai-routers Every saved profile, the built-in `native` one first
|
||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
|
||||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
||||
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||
PATCH /api/ai-routers/:name Update the same fields, all optional
|
||||
DELETE /api/ai-routers/:name Delete one saved profile
|
||||
@@ -2379,6 +2408,8 @@ A **router profile** is an endpoint that stands in front of Claude Code or Codex
|
||||
|
||||
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
|
||||
|
||||
`behavesAs` (optional, `""` clears it, omitted on `PATCH` keeps it) is a Claude model id (`claude-…`, else `400 EBADMODEL`) that Claude Code should treat the router's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as `agent` — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the router really sends a name to.
|
||||
|
||||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||||
|
||||
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude workspace's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
|
||||
@@ -2389,7 +2420,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
|
||||
|
||||
**Starting a session on a router.** `POST /api/lanes/:id/start` and `POST /api/run` take `router: "<name>"` (omitted, `""` or `"native"` = the vendor's own API). Only the name comes from the request; the key and endpoint come from the store. An unknown name is `400 EBADROUTER`, a router on a tool other than Claude/Codex `400 EUNSUPPORTED`. A resume (`resumeSessionId`, `resume`) that names no router goes back to the one its session last ran on (`dashboard_runs.router`), or native once that profile is deleted. Each spawn (re)writes the profile's files:
|
||||
|
||||
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
|
||||
- **Claude** gets `--settings=<data dir>/router-settings/<name>.json` (dir `0700`, file `0600`): an `env` block with the eight keys `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL` (an empty one left out), plus — only when the profile sets `behavesAs` — `modelPicker: { options: [{ model, behavesAs }] }` with one row per distinct model name that is not a bare `claude-*` id. Claude Code reads `modelPicker` from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a `modelPicker` in the user's own `~/.claude/settings.json` for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two routers, and only the path is in argv. Native adds nothing, so a router the user set in their own `~/.claude/settings.json` still applies to a native session.
|
||||
- **Codex** gets `-p <name>` and `$CODEX_HOME/<name>.config.toml`, the layer that flag loads on top of the base config (never touched). The layer sets `wire_api = "responses"` (codex-cli 0.149 refuses `"chat"`); `env_key` is written only when the profile has a key, and the key reaches the pane as `AGENTHUB_ROUTER_KEY` through tmux's own `-e`, never as typed text.
|
||||
|
||||
`PATCH` rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and `DELETE` removes both. Workspaces no longer carry a router: `POST /api/lanes/:id/router/apply` is gone, and at every start the server takes the eight keys an older build wrote into a workspace's `.claude/settings.local.json` back out and clears `lanes.router_profile` (a file that is not valid JSON is left alone and retried next start).
|
||||
@@ -2405,7 +2436,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||
```
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens **Settings → Pet** (`/settings/pet`, `client/src/components/settings/PetSettings.tsx`): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
|
||||
+3
-3
@@ -155,7 +155,7 @@ classDiagram
|
||||
|
||||
## 4) Language Detection and Switching Flow
|
||||
|
||||
The language picker in Settings → General (`client/src/pages/Settings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
|
||||
The language picker in Settings → Appearance (`client/src/components/settings/AppearanceSettings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -237,7 +237,7 @@ npm run test:client
|
||||
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
|
||||
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
|
||||
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
|
||||
| Runtime switching | UI rerenders without reload | Settings → General language picker |
|
||||
| Runtime switching | UI rerenders without reload | Settings → Appearance language picker |
|
||||
|
||||
---
|
||||
|
||||
@@ -282,7 +282,7 @@ gantt
|
||||
### Operational checklist
|
||||
- [ ] Confirm all namespaces exist for `en` and `vi`
|
||||
- [ ] Confirm key parity across all locale JSON files
|
||||
- [ ] Confirm language switching from Settings → General re-renders the whole app
|
||||
- [ ] Confirm language switching from Settings → Appearance re-renders the whole app
|
||||
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
|
||||
- [ ] Confirm date/time/currency formatting for all supported languages
|
||||
- [ ] Confirm client tests pass before release
|
||||
|
||||
+50
-29
@@ -396,7 +396,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
|
||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub workspace add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering** — drag a row and drop it anywhere in the list (a line shows where it lands, by the pointer's position — on another row, on a session list, above the first or below the last), or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub workspaces` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
||||
@@ -406,7 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
|
||||
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
|
||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
|
||||
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
|
||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
|
||||
|
||||
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
||||
@@ -492,8 +492,9 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
read off disk, since a workspace mid-build can have a 100 MB artifact), a **binary**
|
||||
file, a **rename** (numstat keys those differently), or a repo with no commit
|
||||
to diff against yet.
|
||||
- Hovering (or focusing, or tapping) the `N modified · N untracked` text at the
|
||||
top of the Changes panel opens this list as a panel: one row per file, its status letter
|
||||
- Hovering (or focusing, or tapping) the vertical `N modified · N untracked` chip
|
||||
on the CLOSED Changes rail opens this list as a panel (the open panel shows no
|
||||
counts — its own list below says the same): one row per file, its status letter
|
||||
colour-coded, the directory truncated before the filename ever is, and each
|
||||
row's `+N −M` aligned on the right under a total.
|
||||
- A repo with no commit yet is **not** unavailable: it reports `unborn: true`,
|
||||
@@ -600,23 +601,20 @@ persists per viewer in `localStorage`). Its header holds **one** control, which
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
|
||||
height-locked so the panel fills the viewport even with no live run; the whole
|
||||
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
|
||||
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
|
||||
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
|
||||
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
|
||||
restores the saved text) → closed (`agenthub.workspace.changesSize`
|
||||
is dropped; a file opens on its **Changes** like at any width, and
|
||||
**Whole file** is the editor — **Save**/**Cancel** wake up once the text
|
||||
differs, Cancel restores the saved text) → closed (`agenthub.workspace.changesSize`
|
||||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||||
narrow. Between the console and the open (not full-screen) panel sits a **splitter**: drag it, or move it with the arrow keys, to any width between 15% and 85%; that fraction is stored as a NUMBER in the same `agenthub.workspace.changesSize` key, and the header button still steps through the presets. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
|
||||
which one is "work" changes several times an hour, so the width is a step, not
|
||||
a setting — and one button beats a width button wedged beside a collapse button
|
||||
in the panel's tightest corner. It is icon-only, and the icon it draws is the
|
||||
width the next click LANDS on (even split → wider → closed), with the wording
|
||||
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
|
||||
of two icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
|
||||
**Files** (the browse mode below, on its own — no git header, no mode
|
||||
switcher) and **Changes** (the git header and the other four modes). The
|
||||
workspace's sessions live in the sidebar and on its home only — the panel's
|
||||
former Sessions tab was a third copy of the same list. At Full screen the Files and Changes tabs show the same thing: the
|
||||
explorer sidebar beside the changes. It
|
||||
left to its tooltip. Files and Changes are ONE panel: its mode switcher reads
|
||||
**Changes** (the overview, where it opens) · **Files** · **Review** · **History**
|
||||
(the former activity-bar tabs and `agenthub.workspace.panelTab` are gone). The
|
||||
workspace's sessions live in the sidebar and on its home only. At Full screen
|
||||
Files is the explorer sidebar beside the changes, so its button is dropped. It
|
||||
reads the workspace's git surface in five modes:
|
||||
|
||||
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
|
||||
@@ -629,19 +627,36 @@ reads the workspace's git surface in five modes:
|
||||
one green with `U` (`A` added, `D` deleted and struck through, `R`
|
||||
renamed), and a folder holding any of them gets an amber dot. The colours
|
||||
come with the tree read, so **Refresh** re-reads them.
|
||||
Opening a file entry shows the whole file (`/git/file`) for reading, at every
|
||||
panel width; an image file (png, jpg, gif, webp, avif, bmp, ico, svg) is shown
|
||||
as the picture (`/git/raw`), with no editor. **Clicking its text** (or Enter on it; any range but a commit)
|
||||
turns that view into a text editor with the caret on the clicked line and
|
||||
the scroll position kept — a click that only ends a drag-selection does not,
|
||||
so copying never drops you into the editor. **Cancel** returns to reading.
|
||||
From a diff, which has no whole text to click, an **Edit** button does the
|
||||
same once the file has loaded. **Save** (or
|
||||
A changed file opens on its **Changes**, also when picked in Files; an
|
||||
unchanged file picked in Files opens as the whole file. Any working file
|
||||
(not a commit's, a binary, a deleted or a conflicted one) is the **editor**
|
||||
in both views (`client/src/components/lanes/DiffEditor.tsx`, CodeMirror 6
|
||||
with `@codemirror/merge`, lazy-loaded), editable, with its diff against
|
||||
HEAD (`work`) or the branch's merge base (`branch`, `mergeBase` on
|
||||
`/git/changes`): **Changes** folds the unchanged stretches away (a click on
|
||||
a fold opens it), **Whole file** shows them, and the toggle keeps the draft.
|
||||
Unified puts the removed lines read-only above their replacement; side by
|
||||
side puts the original, read-only, left of the file. Every changed block
|
||||
carries a **Revert** (`→` between split columns) that puts the original back
|
||||
in the editor — nothing is written until Save. The panel opens side by side
|
||||
at 50/50 and full width (Workspace's `wide`) and unified when narrower; the
|
||||
layout button overrides it until the next resize. The colours follow every
|
||||
keystroke: **green** is a line added whole, **red** a line removed whole,
|
||||
**blue** a line edited in place (it keeps a letter or digit of its old
|
||||
text), its edit in a stronger blue; the read-only diffs (Review, a commit, a
|
||||
file the editor cannot take) use the same scheme, and only they carry the
|
||||
per-block Stage/Undo review bars and the line-comment buttons — Review
|
||||
keeps both for every file. In a read-only side-by-side diff, every row is at
|
||||
least one line tall (`MIN_ROW`), so an empty filler row keeps the two
|
||||
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
|
||||
shown as the picture (`/git/raw`), and a conflicted file opens in the
|
||||
conflict view. **Cancel** reverts unsaved edits. **Save** (or
|
||||
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
|
||||
exact version that was opened — a file the agent changed meanwhile answers
|
||||
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
|
||||
line endings and final newline. A file the editor cannot take (binary, or
|
||||
over the 1 MB view cap) says so in the file's header.
|
||||
line endings and final newline; the editor stays open on what was saved. A
|
||||
file the editor cannot take (binary, or over the 1 MB view cap) says so in
|
||||
the file's header.
|
||||
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
|
||||
it is the cheap shape and the one you leave open while an agent works. Each
|
||||
row carries its own **stage** / **unstage** / **discard** buttons.
|
||||
@@ -675,8 +690,9 @@ reads the workspace's git surface in five modes:
|
||||
**All files** returns to the list it was opened from — the same mode, at the
|
||||
same scroll offset — because reading a change-set is a walk down a long list
|
||||
and landing back at its top is the whole cost of opening one file.
|
||||
- **History** — the workspace's recent commits (`GET /:id/git/log`); opening one
|
||||
shows that commit's diff (`?range=commit&sha=`).
|
||||
- **History** — the workspace's recent commits (`GET /:id/git/log`) drawn as a
|
||||
commit graph, one coloured lane per branch line (`client/src/lib/commitGraph.ts`);
|
||||
opening one shows that commit's diff (`?range=commit&sha=`).
|
||||
|
||||
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
|
||||
working tree, untracked files included as an all-added patch) and **vs base**
|
||||
@@ -712,14 +728,19 @@ overview grows a commit box the moment anything is staged — above the list and
|
||||
pinned while it scrolls, on ONE row with a **Stage all** that stages every listed file
|
||||
the index lacks in one call and an **Unstage all** that unstages every staged one — it sends
|
||||
`only: "staged"`, never `add -A`, and it is absent with a clean index because
|
||||
there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
|
||||
there is nothing it could mean. A file you never want committed (a local note, a scratch folder) can be marked **Never stage** (its row's eye-off button, or right-click): **Stage all** and a folder's stage button then skip it, the row is struck through, and the file is drawn orange in the Files tree (`--kept`, apart from the amber of a modified file). The list is kept in this browser per workspace (`agenthub.changes.neverStage.<lane id>`) and git is never told — nothing is written to the repo, and the file's own Stage still works. It is the dashboard's ONLY commit: the git bar
|
||||
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
|
||||
irreversible action in the panel: it asks twice in the UI, and the server
|
||||
refuses it without `confirm: true` and accepts only paths `git status` is
|
||||
already reporting as changed. In the `work` range the overview splits like VS
|
||||
Code's Source Control: **Staged Changes** (what the next commit takes, each row
|
||||
offering Unstage) above **Changes** (unstaged and untracked, each offering
|
||||
Stage); a partly staged file appears in both. In the tree, a changed file's
|
||||
Stage); a partly staged file appears in both. Each group is a folder tree like
|
||||
JetBrains' commit view (`client/src/lib/changeTree.ts`): folders first, a
|
||||
chain of folders holding only one sub-folder compacted into one row
|
||||
(`src/lib`), each folder row showing its file count, folding on a click and —
|
||||
in Staged Changes / Changes — carrying one button that unstages / stages every
|
||||
file under it; the group headers fold too. The fold state is not stored. In the tree, a changed file's
|
||||
whole row takes its status tint, not only its letter.
|
||||
|
||||
Review, single-file, history and browse do **not** poll: re-fetching under the
|
||||
|
||||
+83
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.1.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.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -10630,6 +10630,50 @@ paths:
|
||||
error:
|
||||
code: EBADORIGIN
|
||||
message: cross-origin requests are not allowed
|
||||
/api/run/history/{id}:
|
||||
delete:
|
||||
tags:
|
||||
- Run
|
||||
summary: Forget a closed Terminal tab
|
||||
description: Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.
|
||||
operationId: runHistoryForget
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The history row's id (RunHistoryItem.id).
|
||||
example: agenthub-lane-5-2@2026-10-06T08:00:00.000Z
|
||||
responses:
|
||||
'200':
|
||||
description: Removed
|
||||
content:
|
||||
application/json:
|
||||
example:
|
||||
removed: agenthub-lane-5-2
|
||||
'403':
|
||||
description: 'Cross-origin rejected (EBADORIGIN). Enforced by the router-level loopback same-origin guard on every /api/run route: a browser request carrying an Origin/Referer whose host is not localhost / 127.0.0.1 / ::1 / 0.0.0.0 is refused. Requests without an Origin header (e.g. curl) are allowed.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
error:
|
||||
code: EBADORIGIN
|
||||
message: cross-origin requests are not allowed
|
||||
'404':
|
||||
description: No closed Terminal tab with that id (ENOTFOUND)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: That terminal is still open (ERUNLIVE)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/run/cwds:
|
||||
get:
|
||||
tags:
|
||||
@@ -11594,6 +11638,44 @@ paths:
|
||||
description: '{available, branches, current, worktrees}.'
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/commit-message:
|
||||
post:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: Suggest a commit message for the staged changes
|
||||
description: Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools "" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.
|
||||
operationId: suggestLaneCommitMessage
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: session
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The suggested message
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
'400':
|
||||
description: ENOSTAGED / ENOTOOL / ENOWORKTREE
|
||||
'403':
|
||||
description: Cross-origin request refused
|
||||
'404':
|
||||
description: No such workspace
|
||||
'502':
|
||||
description: EAI — the claude CLI failed
|
||||
/api/lanes/{id}/git/{action}:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.1",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -164,6 +164,21 @@ describe("ai-routers — update", () => {
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||||
});
|
||||
|
||||
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
|
||||
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
|
||||
assert.equal(created.behavesAs, "claude-opus-5-5");
|
||||
assert.equal(
|
||||
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
|
||||
"claude-opus-5-5"
|
||||
);
|
||||
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
|
||||
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
|
||||
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
|
||||
code: "EBADMODEL",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — remove", () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file Tests for server/lib/commit-message.js against a real git repo and a
|
||||
* fake `claude` script: only staged, in-lane, non-secret changes reach the
|
||||
* model; its argv keeps tools, hooks and persistence off; its answer is
|
||||
* cleaned; and every failure is a coded error.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { suggestCommitMessage, buildInput } = require("../lib/commit-message");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commitmsg-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
const SUB = path.join(REPO, "sub");
|
||||
const ARGS = path.join(ROOT, "args.txt");
|
||||
const STDIN = path.join(ROOT, "stdin.txt");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
execFileSync("git", ["-c", "user.email=t@h", "-c", "user.name=t", ...args], { cwd: REPO, env });
|
||||
};
|
||||
const write = (file, body) => {
|
||||
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(REPO, file), body);
|
||||
};
|
||||
/** A fake claude: records argv and stdin, prints `reply`, exits `code`. */
|
||||
function fakeClaude(reply, code = 0) {
|
||||
const bin = path.join(ROOT, `claude-${Math.random().toString(36).slice(2)}`);
|
||||
fs.writeFileSync(
|
||||
bin,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$@" > '${ARGS}'\ncat > '${STDIN}'\nprintf '${reply}'\nexit ${code}\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
return bin;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
fs.mkdirSync(REPO);
|
||||
g("init", "-q", "-b", "main");
|
||||
write("a.txt", "one\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-q", "-m", "feat: seed");
|
||||
});
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
describe("commit-message", () => {
|
||||
it("refuses with ENOSTAGED when nothing is staged", async () => {
|
||||
write("a.txt", "one\nunstaged\n");
|
||||
await assert.rejects(buildInput(REPO), { code: "ENOSTAGED" });
|
||||
});
|
||||
|
||||
it("sends only staged, non-secret changes and keeps tools/hooks/persistence off", async () => {
|
||||
write("b.txt", "staged line\n");
|
||||
write(".claude/settings.local.json", '{"env":{"KEY":"sk-secret"}}\n');
|
||||
g("add", "-f", "b.txt", ".claude/settings.local.json");
|
||||
const msg = await suggestCommitMessage(REPO, { bin: fakeClaude("```\\nfeat: add b\\n```") });
|
||||
assert.equal(msg, "feat: add b");
|
||||
const stdin = fs.readFileSync(STDIN, "utf8");
|
||||
assert.match(stdin, /feat: seed/);
|
||||
assert.match(stdin, /staged line/);
|
||||
assert.doesNotMatch(stdin, /unstaged|sk-secret|settings\.local/);
|
||||
const argv = fs.readFileSync(ARGS, "utf8").split("\n");
|
||||
for (const flag of ["-p", "--safe-mode", "--no-session-persistence", "--tools"]) {
|
||||
assert.ok(argv.includes(flag), flag);
|
||||
}
|
||||
assert.equal(argv[argv.indexOf("--tools") + 1], "");
|
||||
});
|
||||
|
||||
it("scopes a subdirectory lane to its own staged files", async () => {
|
||||
write("sub/c.txt", "inside\n");
|
||||
g("add", "sub/c.txt");
|
||||
const input = await buildInput(SUB);
|
||||
assert.match(input, /inside/);
|
||||
assert.doesNotMatch(input, /staged line/);
|
||||
});
|
||||
|
||||
it("reports a failing or silent CLI as EAI", async () => {
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
|
||||
code: "EAI",
|
||||
});
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("") }), { code: "EAI" });
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,36 @@ describe("dashboard-runs", () => {
|
||||
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
|
||||
});
|
||||
|
||||
it("forgets a closed Terminal tab and nothing of another tool", () => {
|
||||
start("agenthub-lane-9", { laneId: 9, tool: "claude" });
|
||||
start("agenthub-lane-9-1", { laneId: 9, tool: "shell" });
|
||||
assert.equal(runs.forgetShellRun("agenthub-lane-9"), false);
|
||||
assert.ok(runs.getRun("agenthub-lane-9"));
|
||||
assert.equal(runs.forgetShellRun("agenthub-lane-9-1"), true);
|
||||
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
|
||||
});
|
||||
|
||||
it("keeps only the newest closed Terminal tabs of a lane, live ones untouched", () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
start(`agenthub-lane-11-${i}`, {
|
||||
laneId: 11,
|
||||
tool: "shell",
|
||||
startedAt: Date.parse(`2026-10-0${i}T10:00:00Z`),
|
||||
});
|
||||
}
|
||||
start("agenthub-lane-11", { laneId: 11, tool: "claude", startedAt: Date.parse("2026-09-01") });
|
||||
// Pane 5 still runs; every other one has closed.
|
||||
runs.reconcileOrphans((id) => id === "agenthub-lane-11-5");
|
||||
const shells = runs.listRuns({ laneId: 11, tool: "shell" }).map((r) => r.id);
|
||||
assert.deepEqual(shells, [
|
||||
"agenthub-lane-11-5",
|
||||
"agenthub-lane-11-4",
|
||||
"agenthub-lane-11-3",
|
||||
"agenthub-lane-11-2",
|
||||
]);
|
||||
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -195,6 +195,7 @@ describe("collectChanges — branch range", () => {
|
||||
|
||||
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
|
||||
assert.equal(out.baseRef, "origin/development");
|
||||
assert.equal(out.mergeBase, g(LANE, "merge-base", "origin/development", "HEAD").trim());
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["a.txt"]
|
||||
@@ -401,6 +402,8 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
|
||||
["touch keep", "touch a", "init"]
|
||||
);
|
||||
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
|
||||
assert.deepEqual(all[0].parents, [all[1].sha]);
|
||||
assert.deepEqual(all[2].parents, []);
|
||||
|
||||
const forA = await listCommits(LANE, { file: "a.txt" });
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -185,7 +185,8 @@ describe("lane-sources instructions and launch", () => {
|
||||
let argv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
|
||||
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
||||
|
||||
@@ -1105,6 +1105,11 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
|
||||
assert.equal(bogus.status, 400);
|
||||
assert.equal(bogus.body.error.code, "EBADACTION");
|
||||
|
||||
// Its own route, not an action: never reaches claude with nothing staged.
|
||||
const suggest = await request("POST", `/api/lanes/${laneId}/git/commit-message`, {});
|
||||
assert.equal(suggest.status, 400);
|
||||
assert.equal(suggest.body.error.code, "ENOSTAGED");
|
||||
});
|
||||
|
||||
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
|
||||
|
||||
@@ -75,11 +75,9 @@ describe("pty-run", () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
|
||||
const flag = path.join(bin, "attached");
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "tmux"),
|
||||
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
fs.writeFileSync(path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const child = spawn("sh", ["-c", argv[2]], {
|
||||
@@ -492,11 +490,43 @@ describe("pty-run", () => {
|
||||
return calls.find((c) => c[0] === "new-session");
|
||||
}
|
||||
|
||||
/** The pane's own command line — everything tmux passes after the `--`. */
|
||||
/** The CLI's own command line: what tmux passes after the `--`, less the
|
||||
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
|
||||
function paneArgv(newSessionCall) {
|
||||
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
||||
assert.match(argv[2], /session_attached.*exec "\$@"$/);
|
||||
return argv.slice(4);
|
||||
}
|
||||
|
||||
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
|
||||
// A claude drawn at tmux's birth size duplicated its banner on the first
|
||||
// resize; the argv rides as positional parameters, never script text.
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
|
||||
);
|
||||
const argv = paneArgv(call);
|
||||
assert.ok(argv.includes("a'; rm -rf / #"));
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const out = execFileSync(
|
||||
"sh",
|
||||
[
|
||||
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
||||
"sh",
|
||||
"printf",
|
||||
"%s|",
|
||||
"x y",
|
||||
"$HOME",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
assert.equal(out, "x y|$HOME|");
|
||||
});
|
||||
|
||||
it("spawnRun builds a codex argv for a codex lane", () => {
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
@@ -583,15 +613,11 @@ describe("pty-run", () => {
|
||||
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
|
||||
});
|
||||
|
||||
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
|
||||
it("leaves a claude pane's screen mode to the user's own tui setting", () => {
|
||||
// Forcing it inline duplicated the transcript on every pane narrowing.
|
||||
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
|
||||
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
|
||||
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
|
||||
tmux.__reset();
|
||||
const codex = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
|
||||
);
|
||||
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
|
||||
assert.ok(!envFlags(claude).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
|
||||
});
|
||||
|
||||
describe("catalog tools", () => {
|
||||
@@ -719,7 +745,11 @@ describe("pty-run", () => {
|
||||
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
live = true;
|
||||
t.mock.timers.tick(3000);
|
||||
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
|
||||
// waiting for a client to attach.
|
||||
t.mock.timers.tick(4999);
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
t.mock.timers.tick(1);
|
||||
assert.deepEqual(
|
||||
calls.find((c) => c[0] === "set-buffer"),
|
||||
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -119,6 +119,33 @@ describe("routerForSpawn — Claude", () => {
|
||||
it("adds nothing for native", () => {
|
||||
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
|
||||
});
|
||||
|
||||
it("writes no modelPicker without behavesAs, so the user's own one still applies", () => {
|
||||
const { name } = saveNineRouter();
|
||||
spawnFor("claude", name);
|
||||
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
|
||||
assert.equal("modelPicker" in written, false);
|
||||
});
|
||||
|
||||
it("maps every distinct router model name with behavesAs (issue #56)", () => {
|
||||
const { name } = routers.createRouter({
|
||||
label: "9Router combo",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "agent",
|
||||
models: { haiku: "agent-fast", subagent: "claude-haiku-4-5-20251001" },
|
||||
behavesAs: "claude-opus-5-5",
|
||||
});
|
||||
spawnFor("claude", name);
|
||||
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
|
||||
// A bare claude-* id is one Claude Code knows; mapping it would override
|
||||
// its real handling.
|
||||
assert.deepEqual(written.modelPicker, {
|
||||
options: [
|
||||
{ model: "agent", behavesAs: "claude-opus-5-5" },
|
||||
{ model: "agent-fast", behavesAs: "claude-opus-5-5" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("routerForSpawn — Codex", () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("tmux wrapper", () => {
|
||||
"-t",
|
||||
"=agenthub-lane-1:",
|
||||
"pane-scrollbars-style",
|
||||
"fg=brightwhite,bg=black,width=2,pad=0",
|
||||
"fg=brightwhite,bg=black,width=1,pad=0",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -189,6 +189,21 @@ function normalizeModels(model, models) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A known Claude model id that Claude Code should treat the router's own model
|
||||
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
|
||||
* The user picks it, never AgentHub: it also sets the context window Claude
|
||||
* Code compacts at, and a router may route a name to a non-Claude model.
|
||||
*/
|
||||
function normalizeBehavesAs(value) {
|
||||
const clean = typeof value === "string" ? value.trim() : null;
|
||||
if (clean === "") return "";
|
||||
if (!clean || !/^claude-[a-z0-9][a-z0-9.-]{0,99}$/.test(clean)) {
|
||||
throw err("EBADMODEL", "behavesAs must be a Claude model id, e.g. claude-opus-5-5");
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function assertNotBuiltin(name) {
|
||||
if (name === BUILTIN_NAME) {
|
||||
throw err("EBUILTIN", `'${BUILTIN_NAME}' is the built-in profile and cannot be changed`);
|
||||
@@ -250,7 +265,7 @@ function getRouter(name) {
|
||||
|
||||
/**
|
||||
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
||||
* models?:Record<string,string>}} input
|
||||
* models?:Record<string,string>, behavesAs?:string}} input
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createRouter(input = {}) {
|
||||
@@ -265,6 +280,7 @@ function createRouter(input = {}) {
|
||||
apiKey: typeof input.apiKey === "string" ? input.apiKey.trim() : "",
|
||||
model,
|
||||
models: normalizeModels(model, input.models),
|
||||
behavesAs: input.behavesAs === undefined ? "" : normalizeBehavesAs(input.behavesAs),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -297,6 +313,10 @@ function updateRouter(name, changes = {}) {
|
||||
changes.model === undefined && changes.models === undefined
|
||||
? stored.models
|
||||
: normalizeModels(model, changes.models ?? stored.models),
|
||||
behavesAs:
|
||||
changes.behavesAs === undefined
|
||||
? (stored.behavesAs ?? "")
|
||||
: normalizeBehavesAs(changes.behavesAs),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -491,6 +491,7 @@ module.exports = {
|
||||
readProfile,
|
||||
writeProfile,
|
||||
liveCredentialsFor,
|
||||
readActiveAuth,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @file commit-message.js
|
||||
* @description Suggests a commit message for a lane's STAGED changes with the
|
||||
* user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with
|
||||
* no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on
|
||||
* disk (`--no-session-persistence`), so the call neither acts on the repo nor
|
||||
* shows up as a session in the dashboard. Read-only on git; the diff is scoped
|
||||
* to the lane's cwd and never carries a secret file (`isSecretOnDisk`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const worktree = require("./worktree");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { isSecretOnDisk } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
|
||||
|
||||
/** Diff characters sent to the model; the rest is cut and marked. */
|
||||
const MAX_DIFF_CHARS = 60_000;
|
||||
/** A `claude -p` that has not answered by then is killed. */
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
const PROMPT =
|
||||
"Write a git commit message for the staged diff given on stdin. " +
|
||||
"Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " +
|
||||
"matching the style of the recent subjects listed there. Add a short body after a blank line only " +
|
||||
"when the change needs explaining. Output ONLY the commit message — no code fences, no commentary.";
|
||||
|
||||
function bad(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/** The text the model reads: recent subjects, the staged file list, the diff. */
|
||||
async function buildInput(dir) {
|
||||
const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const paths = [];
|
||||
for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p);
|
||||
if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace");
|
||||
|
||||
let diff;
|
||||
try {
|
||||
diff = (
|
||||
await git(
|
||||
dir,
|
||||
["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths],
|
||||
{ maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 }
|
||||
)
|
||||
).stdout;
|
||||
} catch (err) {
|
||||
// An oversized diff: git's partial output is still the useful head.
|
||||
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
|
||||
diff = String(err.git.stdout);
|
||||
}
|
||||
if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
|
||||
|
||||
const log = await git(dir, ["log", "-10", "--format=%s"])
|
||||
.then((r) => r.stdout.trim())
|
||||
.catch(() => ""); // no commit yet
|
||||
|
||||
return [
|
||||
log ? `Recent commit subjects:\n${log}` : "",
|
||||
`Staged files:\n${paths.join("\n")}`,
|
||||
`Staged diff:\n${diff}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/** Run `bin` with `args`, `input` on stdin; resolve its stdout. */
|
||||
function run(bin, args, input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let errOut = "";
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (errOut += d));
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(bad("EAI", err.message));
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) return resolve(out);
|
||||
const why = signal ? "timed out" : (errOut || out).trim().slice(-500) || `exit ${code}`;
|
||||
reject(bad("EAI", `claude failed: ${why}`));
|
||||
});
|
||||
child.stdin.on("error", () => {}); // a CLI that exits early closes stdin
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested message for what `dir` has staged.
|
||||
* @param {string} dir the lane's (or session worktree's) cwd
|
||||
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function suggestCommitMessage(dir, { bin } = {}) {
|
||||
const input = await buildInput(dir);
|
||||
const claude = bin || aiTools.detect("claude", { fresh: true }).path;
|
||||
if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH");
|
||||
const out = await run(
|
||||
claude,
|
||||
["-p", "--safe-mode", "--no-session-persistence", "--tools", "", "--model", "haiku", PROMPT],
|
||||
input
|
||||
);
|
||||
const message = out
|
||||
.trim()
|
||||
.replace(/^```[a-z]*\n?|\n?```$/g, "")
|
||||
.trim();
|
||||
if (!message) throw bad("EAI", "claude returned an empty message");
|
||||
return message;
|
||||
}
|
||||
|
||||
module.exports = { suggestCommitMessage, buildInput };
|
||||
@@ -165,6 +165,14 @@ function listRuns({ limit = 50, laneId = null, tool = null } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const forgetShellStmt = db.prepare(`DELETE FROM dashboard_runs WHERE id = ? AND tool = 'shell'`);
|
||||
|
||||
/** Drop a closed Terminal tab's row — a shell has no session, so the row is
|
||||
* only the sidebar's "recently closed" entry. Never touches another tool's. */
|
||||
function forgetShellRun(id) {
|
||||
return forgetShellStmt.run(id).changes > 0;
|
||||
}
|
||||
|
||||
function getRun(id) {
|
||||
try {
|
||||
return getStmt.get({ id }) || null;
|
||||
@@ -255,12 +263,28 @@ const abandonStmt = db.prepare(`
|
||||
WHERE id = ? AND status IN ('running', 'spawning')
|
||||
`);
|
||||
|
||||
/** Closed Terminal tabs kept per lane — what the sidebar lists
|
||||
* (`CLOSED_TERMINALS` in WorkspaceSessionList.tsx). */
|
||||
const CLOSED_SHELLS_KEPT = 3;
|
||||
// A shell has no session, so its row is only that list's entry: one the list
|
||||
// cannot show is dropped, or removing the shown ones uncovers older ones.
|
||||
const pruneShellsStmt = db.prepare(`
|
||||
DELETE FROM dashboard_runs WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY lane_id ORDER BY started_at DESC) AS n
|
||||
FROM dashboard_runs
|
||||
WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
|
||||
) WHERE n > ${CLOSED_SHELLS_KEPT}
|
||||
)
|
||||
`);
|
||||
|
||||
/**
|
||||
* Flags `abandoned` every row still marked running/spawning whose pane is
|
||||
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
|
||||
* A pane is a tmux session, which outlives a server restart, so liveness is
|
||||
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
|
||||
* and on the server's run-heal beat.
|
||||
* and on the server's run-heal beat, which also drops the closed Terminal
|
||||
* tabs past the newest {@link CLOSED_SHELLS_KEPT} of each lane.
|
||||
*
|
||||
* Returns the number of rows updated.
|
||||
*/
|
||||
@@ -270,6 +294,7 @@ function reconcileOrphans(isAlive = () => false) {
|
||||
for (const { id } of runningStmt.all()) {
|
||||
if (!isAlive(id)) changes += abandonStmt.run(id).changes;
|
||||
}
|
||||
pruneShellsStmt.run();
|
||||
return changes;
|
||||
} catch {
|
||||
return 0;
|
||||
@@ -281,6 +306,7 @@ module.exports = {
|
||||
patchRun,
|
||||
listRuns,
|
||||
getRun,
|
||||
forgetShellRun,
|
||||
reconcileOrphans,
|
||||
routerForSession,
|
||||
laneForSession,
|
||||
|
||||
+14
-5
@@ -508,7 +508,7 @@ async function synthesizeAddedPatch(dir, file) {
|
||||
* `file` is set, which is already a full read of one file.
|
||||
* @param {number} [opts.context] Unchanged lines around each hunk. The panel
|
||||
* raises this to expand a diff towards the whole file.
|
||||
* @returns {Promise<{range, base, baseRef, sha, context, files, truncated}>}
|
||||
* @returns {Promise<{range, base, baseRef, mergeBase, sha, context, files, truncated}>}
|
||||
*/
|
||||
async function collectChanges(
|
||||
dir,
|
||||
@@ -520,11 +520,17 @@ async function collectChanges(
|
||||
const ctx = normalizeContext(context);
|
||||
|
||||
let baseRef = null;
|
||||
// The commit a branch diff is taken from (`<baseRef>...HEAD`): the editor
|
||||
// reads a file's original text there.
|
||||
let mergeBase = null;
|
||||
if (range === "branch") {
|
||||
baseRef = await resolveBaseRef(dir, base);
|
||||
if (!baseRef) {
|
||||
throw badRequest("ENOBASE", "no base branch to compare against");
|
||||
}
|
||||
mergeBase = await git(dir, ["merge-base", baseRef, "HEAD"])
|
||||
.then((r) => r.stdout.trim() || null)
|
||||
.catch(() => null);
|
||||
}
|
||||
if (range === "commit") {
|
||||
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision");
|
||||
@@ -598,7 +604,7 @@ async function collectChanges(
|
||||
}
|
||||
}
|
||||
|
||||
return { range, base, baseRef, sha, context: ctx, files, truncated };
|
||||
return { range, base, baseRef, mergeBase, sha, context: ctx, files, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,8 +621,10 @@ async function collectChanges(
|
||||
*/
|
||||
async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
|
||||
const args = ["log", `-${n}`, `--format=${format}%x1e`];
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
|
||||
// --date-order: no parent before all its children, which the history's
|
||||
// commit graph needs to draw a lane down to each parent.
|
||||
const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`];
|
||||
// Through the same lexical guard as every other path: in a subdirectory
|
||||
// lane `../x` would list commit metadata for a path outside the lane.
|
||||
if (file) args.push("--", lanePath(file));
|
||||
@@ -625,7 +633,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
for (const record of result.stdout.split("\x1e")) {
|
||||
const line = record.replace(/^\n/, "");
|
||||
if (!line.trim()) continue;
|
||||
const [sha, short, subject, author, date, refs] = line.split("\x1f");
|
||||
const [sha, short, subject, author, date, refs, parents] = line.split("\x1f");
|
||||
commits.push({
|
||||
sha,
|
||||
short,
|
||||
@@ -638,6 +646,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
.split(", ")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean),
|
||||
parents: (parents || "").split(" ").filter(Boolean),
|
||||
});
|
||||
}
|
||||
return commits;
|
||||
|
||||
+37
-19
@@ -60,6 +60,23 @@ const ALLOWED_PERMISSION_MODES = new Set([
|
||||
]);
|
||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/**
|
||||
* `sh` that waits until a client is attached to the pane — 2 s at most
|
||||
* ({@link CLIENT_WAIT_MS}) — before what follows it runs. A session is born
|
||||
* 80×24 and takes the browser's size only on attach (and `enableMouse`'s
|
||||
* scrollbar narrows it before that). Anything drawn before then is wrong:
|
||||
* a shell's prompt (issue #55), and an inline `claude`'s banner — tmux
|
||||
* re-wraps the 80-column rules into two lines each on the resize, pushing the
|
||||
* banner's first lines into history, and claude's redraw clears only the
|
||||
* visible screen, so they stay above it as a duplicate. Not "the first
|
||||
* SIGWINCH": the scrollbar fires one before any client is there. A tmux that
|
||||
* cannot answer ends the wait at once, i.e. the old behaviour.
|
||||
*/
|
||||
const WAIT_FOR_CLIENT =
|
||||
'i=0; while [ "$(tmux display -p -t "$TMUX_PANE" \'#{session_attached}\' 2>/dev/null)" = 0 ]' +
|
||||
" && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done;";
|
||||
const CLIENT_WAIT_MS = 2000;
|
||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||
@@ -452,11 +469,10 @@ function spawnRun(args) {
|
||||
// journals and the router layer `-p <name>` loads live there — and the
|
||||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||
// Claude Code inline even under `"tui": "fullscreen"`: on the alternate
|
||||
// screen tmux keeps no history and draws no pane scrollbar, so the
|
||||
// dashboard terminal had nothing to drag. The user's own terminals keep
|
||||
// their setting — this reaches only panes AgentHub starts.
|
||||
...(tool === "claude" ? { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
||||
...routerSpawn.env,
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
@@ -472,12 +488,18 @@ function spawnRun(args) {
|
||||
} catch {
|
||||
/* pane gone mid-paste — nothing to deliver to */
|
||||
}
|
||||
}, entry.prompt.delayMs);
|
||||
}, entry.prompt.delayMs + CLIENT_WAIT_MS); // the CLI may start up to that late
|
||||
if (timer.unref) timer.unref();
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
tmux.newSession({ name: id, cwd, argv, env });
|
||||
// argv rides as sh's positional parameters, never inside the script text.
|
||||
tmux.newSession({
|
||||
name: id,
|
||||
cwd,
|
||||
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv],
|
||||
env,
|
||||
});
|
||||
record();
|
||||
schedulePaste();
|
||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||
@@ -530,18 +552,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
};
|
||||
// The session is born 80×24 and only takes the browser's size when the pane
|
||||
// attaches. A shell started at once printed its prompt at 80 columns (a long
|
||||
// cwd wrapped), and readline's redraw after the resize left the cursor on top
|
||||
// of the prompt (issue #55). So wait until a client is attached — 2 s at
|
||||
// most — then exec the login shell tmux would have run (tmux sets SHELL in the
|
||||
// pane). Not "the first SIGWINCH": the scrollbar `enableMouse` turns on
|
||||
// resizes the pane before any client is there. A tmux that cannot answer
|
||||
// ends the wait at once, i.e. the old behaviour.
|
||||
const waitForSize =
|
||||
"i=0; while [ \"$(tmux display -p -t \"$TMUX_PANE\" '#{session_attached}' 2>/dev/null)\" = 0 ]" +
|
||||
' && [ $i -lt 20 ]; do sleep 0.1; i=$((i+1)); done; exec "$SHELL" -l';
|
||||
tmux.newSession({ name: id, cwd, argv: ["sh", "-c", waitForSize], env });
|
||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||||
tmux.newSession({
|
||||
name: id,
|
||||
cwd,
|
||||
argv: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`],
|
||||
env,
|
||||
});
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
|
||||
@@ -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 };
|
||||
@@ -8,7 +8,8 @@
|
||||
* theirs, not a choice made here:
|
||||
*
|
||||
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
||||
* profile's eight `env` keys, `<data dir>/router-settings/<name>.json`
|
||||
* profile's eight `env` keys (plus `modelPicker` rows when the profile
|
||||
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
|
||||
* (0600). The command-line layer outranks the project-local and user
|
||||
* settings (verified on 2.1.286), so two sessions of one workspace can run
|
||||
* on two routers, and the key stays out of argv — only the path is there.
|
||||
@@ -73,12 +74,29 @@ function writeSecret(file, body) {
|
||||
fs.chmodSync(file, 0o600); // an existing file keeps its mode on write
|
||||
}
|
||||
|
||||
/**
|
||||
* `modelPicker` rows telling Claude Code which known model each of the router's
|
||||
* own names behaves as — without one, a name like 9Router's `agent` draws an
|
||||
* "isn't described by this version's model catalog" warning and a 200k
|
||||
* compact window (issue #56). Only when the profile sets `behavesAs`: this
|
||||
* layer's `modelPicker` replaces the user's own for the session. A bare
|
||||
* `claude-*` id is skipped, since Claude Code already knows it.
|
||||
*/
|
||||
function modelPicker(profile) {
|
||||
if (!profile.behavesAs) return {};
|
||||
const names = new Set([profile.model, ...Object.values(profile.models || {})]);
|
||||
const options = [...names]
|
||||
.filter((m) => m && !m.startsWith("claude-"))
|
||||
.map((model) => ({ model, behavesAs: profile.behavesAs }));
|
||||
return options.length ? { modelPicker: { options } } : {};
|
||||
}
|
||||
|
||||
function writeClaudeSettings(profile) {
|
||||
const file = claudeSettingsPath(profile.name);
|
||||
// Empty values are left out: a blank key in the command-line layer would
|
||||
// blank the user's own setting, not leave it alone.
|
||||
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
|
||||
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
|
||||
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -99,13 +99,15 @@ function enableMouse(name) {
|
||||
execImpl(["set-option", "-t", pane(name), "pane-scrollbars", "on"]);
|
||||
// tmux's default (`fg=white,bg=black,width=1`) is a one-column track in
|
||||
// ANSI black — the terminal theme's border colour, almost the background —
|
||||
// so it reads as no scrollbar at all. Two columns, bright slider.
|
||||
// so it reads as no scrollbar at all. Bright slider. ONE column: tmux 3.7c
|
||||
// reserves `width` columns but paints only the first, so a second one is
|
||||
// never cleared and shows stale cells — a ghost second scrollbar.
|
||||
execImpl([
|
||||
"set-option",
|
||||
"-t",
|
||||
pane(name),
|
||||
"pane-scrollbars-style",
|
||||
"fg=brightwhite,bg=black,width=2,pad=0",
|
||||
"fg=brightwhite,bg=black,width=1,pad=0",
|
||||
]);
|
||||
} catch {
|
||||
/* tmux < 3.7 */
|
||||
|
||||
@@ -674,6 +674,37 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/commit-message": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Suggest a commit message for the staged changes",
|
||||
description:
|
||||
"Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools \"\" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.",
|
||||
operationId: "suggestLaneCommitMessage",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "The suggested message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { description: "ENOSTAGED / ENOTOOL / ENOWORKTREE" },
|
||||
403: { description: "Cross-origin request refused" },
|
||||
404: { description: "No such workspace" },
|
||||
502: { description: "EAI — the claude CLI failed" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/{action}": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
@@ -857,6 +857,45 @@ const paths = {
|
||||
},
|
||||
},
|
||||
|
||||
"/api/run/history/{id}": {
|
||||
delete: {
|
||||
tags: ["Run"],
|
||||
summary: "Forget a closed Terminal tab",
|
||||
description:
|
||||
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
|
||||
operationId: "runHistoryForget",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "The history row's id (RunHistoryItem.id).",
|
||||
example: "agenthub-lane-5-2@2026-10-06T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Removed",
|
||||
content: { "application/json": { example: { removed: "agenthub-lane-5-2" } } },
|
||||
},
|
||||
403: ebadOrigin403,
|
||||
404: {
|
||||
description: "No closed Terminal tab with that id (ENOTFOUND)",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "That terminal is still open (ERUNLIVE)",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/run/cwds": {
|
||||
get: {
|
||||
tags: ["Run"],
|
||||
|
||||
@@ -15,6 +15,7 @@ const path = require("node:path");
|
||||
const { db } = require("../db");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
|
||||
const { suggestCommitMessage } = require("../lib/commit-message");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const {
|
||||
listPipelines,
|
||||
@@ -1061,6 +1062,27 @@ router.get("/:id/git/branches", async (req, res) => {
|
||||
* rule about what each action may do lives in server/lib/git-ops.js, not here.
|
||||
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
||||
*/
|
||||
/**
|
||||
* A commit message suggested by the user's `claude` CLI for what is STAGED
|
||||
* (server/lib/commit-message.js). Not a git write — it only reads the index —
|
||||
* but same-origin, because each call spends the user's Claude quota.
|
||||
* Registered before `/:id/git/:action`, which would answer EBADACTION.
|
||||
*/
|
||||
router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json({ message: await suggestCommitMessage(t.cwd) });
|
||||
} catch (err) {
|
||||
const status = ["ENOSTAGED", "ENOTOOL"].includes(err.code)
|
||||
? 400
|
||||
: err.code === "EAI"
|
||||
? 502
|
||||
: 500;
|
||||
res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||
const { quotas } = require("../lib/quota");
|
||||
const {
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
CODE_EXEC_PER_HOUR,
|
||||
@@ -508,6 +509,12 @@ router.get("/cost", (req, res) => {
|
||||
res.json({ ...result, daily_costs });
|
||||
});
|
||||
|
||||
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
|
||||
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
|
||||
router.get("/quota", async (req, res) => {
|
||||
res.json(await quotas({ force: req.query.force === "1" }));
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
|
||||
router.get("/cost/:sessionId", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
|
||||
@@ -158,6 +158,23 @@ router.get("/history", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Forget one closed Terminal tab (`tool = 'shell'`) from the history — the
|
||||
* sidebar's remove. A live pane is refused; another tool's row is a 404.
|
||||
*/
|
||||
router.delete("/history/:id", (req, res) => {
|
||||
if (runs.listRuns().some((h) => h.id === req.params.id)) {
|
||||
return res.status(409).json({ error: { code: "ERUNLIVE", message: "terminal is still open" } });
|
||||
}
|
||||
const dr = require("../lib/dashboard-runs");
|
||||
if (!dr.forgetShellRun(req.params.id)) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOTFOUND", message: "no closed terminal with that id" } });
|
||||
}
|
||||
res.json({ removed: req.params.id });
|
||||
});
|
||||
|
||||
/**
|
||||
* Suggest plausible working directories. Pulls from:
|
||||
* - "dashboard": the dashboard server's cwd
|
||||
|
||||
Reference in New Issue
Block a user