feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
This commit is contained in:
@@ -91,6 +91,11 @@
|
||||
# (default: enabled).
|
||||
# AGENTHUB_PRICING_SYNC=0
|
||||
|
||||
# The status bar's Usage panel reads Antigravity's quota by running
|
||||
# `agy --print /usage --output-format json` (no model call), which may let agy
|
||||
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
|
||||
# AGENTHUB_AGY_USAGE=0
|
||||
|
||||
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
|
||||
# their own connection so they never stall other requests. 0 runs them inline
|
||||
# on the event loop instead (default: worker). A job past the timeout answers
|
||||
|
||||
@@ -48,6 +48,10 @@ open it in the browser and you are done.
|
||||
|
||||
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
|
||||
To update to a new version: `/agenthub:agenthub-update`.
|
||||
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
|
||||
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
|
||||
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
|
||||
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ trên trình duyệt là xong.
|
||||
|
||||
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
|
||||
Cập nhật bản mới: `/agenthub:agenthub-update`.
|
||||
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
|
||||
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
|
||||
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
|
||||
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
|
||||
+67
-4
@@ -40,7 +40,7 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
|
||||
// Resolve the repo root relative to this script's REAL location so the CLI
|
||||
// works both from a checkout (./bin/agenthub.js) and through the global symlink
|
||||
@@ -1397,14 +1397,36 @@ async function cmdReinstallHooks() {
|
||||
* built client) and wait until /api/health answers. No-ops with a pointer to
|
||||
* the live URL when a server is already up. The child is fully detached with
|
||||
* its output appended to data/agenthub-server.log, so closing this terminal does
|
||||
* not stop the dashboard; stop it later with `kill <pid>` (the PID is
|
||||
* printed and registered in ~/.claude/.agenthub.json).
|
||||
* not stop the dashboard; stop it later with `agenthub stop`.
|
||||
*/
|
||||
async function cmdStart(flags) {
|
||||
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
||||
return;
|
||||
}
|
||||
// A plugin install starts the way a Claude Code session does: its bootstrap
|
||||
// knows the runtime dir, the UI bundle and the autostart unit.
|
||||
if (/[\\/]\.claude[\\/]plugins[\\/]cache[\\/]/.test(REPO_ROOT)) {
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"), "--worker"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
}
|
||||
);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
console.error(c.red("✖ Server did not become healthy within 30 s — run /agenthub-doctor"));
|
||||
process.exit(1);
|
||||
}
|
||||
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
|
||||
if (!fs.existsSync(clientDist)) {
|
||||
console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
|
||||
@@ -1447,7 +1469,7 @@ async function cmdStart(flags) {
|
||||
console.log(
|
||||
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
||||
);
|
||||
console.log(c.dim(` Stop it with: kill ${child.pid}`));
|
||||
console.log(c.dim(" Stop it with: agenthub stop"));
|
||||
return;
|
||||
}
|
||||
tick++;
|
||||
@@ -1463,6 +1485,33 @@ async function cmdStart(flags) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit AgentHub (issue #65): every lane pane is killed, the server stops, and
|
||||
* the plugin bootstrap starts none until `agenthub start` — or a boot/login
|
||||
* through an enabled autostart unit (`server/lib/quit.js`).
|
||||
*/
|
||||
async function cmdStop() {
|
||||
if (!(await serverIsUp())) {
|
||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).markQuit();
|
||||
console.log(
|
||||
`${c.dim("○")} Dashboard is not running — it stays stopped until ${c.bold("agenthub start")}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { killed } = await post("/api/settings/quit");
|
||||
const deadline = Date.now() + 15_000;
|
||||
while ((await serverIsUp()) && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
if (await serverIsUp()) {
|
||||
console.error(c.red(`✖ The dashboard at ${baseUrl()} is still answering after 15 s`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`${c.green("✔")} AgentHub stopped${killed ? ` — ${killed} terminal session(s) closed` : ""}. Start it again with: ${c.bold("agenthub start")}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
|
||||
* (macOS) so the dashboard survives reboots without anything (Claude Code
|
||||
@@ -2731,6 +2780,11 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
["status", "", "Up/down indicator for the dashboard server"],
|
||||
["start", "[--port N]", "Start the server in the background and wait for healthy"],
|
||||
[
|
||||
"stop",
|
||||
"",
|
||||
"Quit: close every workspace terminal and stop the server until `agenthub start`",
|
||||
],
|
||||
[
|
||||
"service",
|
||||
"install|uninstall|status [--port N]",
|
||||
@@ -2948,6 +3002,11 @@ const COMMAND_GROUPS = [
|
||||
"",
|
||||
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
|
||||
],
|
||||
[
|
||||
"tray",
|
||||
"",
|
||||
"Show AgentHub's icon in the OS status area now (the server keeps it up; Settings → System hides it)",
|
||||
],
|
||||
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
|
||||
[
|
||||
"statusline install|uninstall",
|
||||
@@ -4000,6 +4059,8 @@ async function runCommand(argv) {
|
||||
return cmdStatus();
|
||||
case "start":
|
||||
return cmdStart(flags);
|
||||
case "stop":
|
||||
return cmdStop();
|
||||
case "service":
|
||||
return cmdService(positional[0], flags);
|
||||
case "server":
|
||||
@@ -4135,6 +4196,8 @@ async function runCommand(argv) {
|
||||
return;
|
||||
}
|
||||
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
|
||||
case "tray":
|
||||
return require(path.join(REPO_ROOT, "scripts", "tray.js")).main();
|
||||
case "open":
|
||||
return cmdOpen();
|
||||
case "version":
|
||||
|
||||
+3
-3
@@ -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 → 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
|
||||
- **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 workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
|
||||
|
||||
**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`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), 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.
|
||||
|
||||
@@ -199,7 +199,7 @@ client/
|
||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
|
||||
│ │ │ ├── ReportSettings.tsx # Settings → Snapshots & reports: scheduled report definitions, Run now, run history + downloads
|
||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
@@ -259,7 +259,7 @@ graph TB
|
||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||
Feed[ActivityFeed<br/>streaming event log]
|
||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
|
||||
Settings[SettingsHub /settings/:section<br/>General: Appearance · Notifications · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · Git (credentials, worktree suggestions)]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||
|
||||
Generated
+10
@@ -14,6 +14,7 @@
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -1066,6 +1067,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/maple-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/maple-mono/-/maple-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-8N3FVDWphO/971tZIig9D558j9jf09fWYIXFjgv7DKJEvDjtgfATx1ScWHdTCsf+tufFS5M1bAa4AumVBKPhSQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
|
||||
@@ -87,6 +87,7 @@ const SessionDetail = lazy(() =>
|
||||
const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -209,6 +210,14 @@ export default function App() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
|
||||
* one per animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
|
||||
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
|
||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||||
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
||||
* own pace and holds its last frame longer, so a loop breathes instead of
|
||||
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,9 +21,29 @@ import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const SHEET = { cols: 8, rows: 9 };
|
||||
/** Frames in each row of the Codex atlas. */
|
||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||||
const COLS = 8;
|
||||
/** `n` frames of `step` ms, the last one `last` ms. */
|
||||
const frames = (n: number, step: number, last: number) =>
|
||||
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
|
||||
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
|
||||
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
|
||||
* how a sleeping pet breathes here. */
|
||||
const DURATIONS = [
|
||||
[280, 110, 110, 140, 140, 320], // idle
|
||||
frames(8, 120, 220), // run right
|
||||
frames(8, 120, 220), // run left
|
||||
frames(4, 140, 280), // waving
|
||||
frames(5, 140, 280), // jumping
|
||||
frames(8, 140, 240), // failed
|
||||
frames(6, 150, 260), // waiting
|
||||
frames(6, 120, 220), // running (working)
|
||||
frames(6, 150, 280), // review
|
||||
];
|
||||
const SLEEP_SLOWDOWN = 6;
|
||||
/** Rows that play three times and settle into idle; the others loop. */
|
||||
const MOMENTS = new Set([3, 4, 5, 8]);
|
||||
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
|
||||
const GAZE_ROW = 9;
|
||||
/** Which row plays for each mood. */
|
||||
const ROW: Record<Mood, number> = {
|
||||
idle: 0,
|
||||
@@ -36,25 +58,13 @@ const ROW: Record<Mood, number> = {
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
||||
const PACE: [number, number][] = [
|
||||
[220, 1400], // idle: a breath, then a pause
|
||||
[120, 120], // run right — keeps up with the drag
|
||||
[120, 120], // run left
|
||||
[180, 400], // waving
|
||||
[160, 700], // jumping
|
||||
[200, 1200], // failed
|
||||
[220, 900], // waiting
|
||||
[170, 400], // running (working)
|
||||
[220, 900], // review
|
||||
];
|
||||
const SLEEP_PACE: [number, number] = [450, 2000];
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
@@ -63,6 +73,8 @@ export function PetSprite({
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
@@ -70,24 +82,30 @@ export function PetSprite({
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const [frame, setFrame] = useState(0);
|
||||
// The cell on show, tagged with the row it was played for: a cell left over
|
||||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||
const [rows, setRows] = useState(9);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
setCell({ of: row, row, col: 0 });
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const count = FRAMES[row] ?? 1;
|
||||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||||
let f = 0;
|
||||
const play = (r: number, slow = 1) =>
|
||||
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
f = (f + 1) % count;
|
||||
setFrame(f);
|
||||
tick();
|
||||
},
|
||||
f === count - 1 ? hold : step
|
||||
);
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
}, list[i]!.ms);
|
||||
};
|
||||
tick();
|
||||
return () => window.clearTimeout(timer);
|
||||
@@ -96,10 +114,13 @@ export function PetSprite({
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
// It also tells a v2 sheet (11 rows) from a v1 (9).
|
||||
const probe = new Image();
|
||||
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onload = null;
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -120,11 +141,18 @@ export function PetSprite({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
|
||||
const shown =
|
||||
pose != null
|
||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||
: cell.of === row
|
||||
? cell
|
||||
: { row, col: 0 };
|
||||
const scale = size / CELL.h;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={row}
|
||||
data-row={shown.row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
@@ -132,8 +160,8 @@ export function PetSprite({
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||||
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
@@ -244,6 +245,32 @@ function SagiBody() {
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
|
||||
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
|
||||
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
|
||||
const btn = useRef<HTMLButtonElement>(null);
|
||||
const [gaze, setGaze] = useState<number | null>(null);
|
||||
const wearsSheet = "kind" in pet && pet.kind === "codex";
|
||||
useEffect(() => {
|
||||
if (!wearsSheet || reducedMotion) return;
|
||||
let rest = 0;
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const r = btn.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const deg =
|
||||
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
|
||||
Math.PI;
|
||||
setGaze(Math.round((deg + 360) / 22.5) % 16);
|
||||
window.clearTimeout(rest);
|
||||
rest = window.setTimeout(() => setGaze(null), 2000);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.clearTimeout(rest);
|
||||
};
|
||||
}, [wearsSheet, reducedMotion]);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
navigate(route);
|
||||
@@ -334,6 +361,7 @@ function SagiBody() {
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={btn}
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
@@ -368,6 +396,7 @@ function SagiBody() {
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
gaze={gaze}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
|
||||
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
|
||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
||||
act(() => vi.advanceTimersByTime(50));
|
||||
act(() => vi.advanceTimersByTime(110));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("holds a loop's last frame before starting over", () => {
|
||||
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
|
||||
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => vi.advanceTimersByTime(310));
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
|
||||
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
|
||||
act(() => vi.advanceTimersByTime(3 * 840 - 1));
|
||||
expect(sprite.dataset.row).toBe("4");
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(sprite.dataset.row).toBe("0");
|
||||
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("looks toward the pointer on a v2 sheet, only while idle", () => {
|
||||
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
naturalHeight = 2288;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// Pose 12 (270°, left) = row 10, column 4.
|
||||
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
|
||||
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
|
||||
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
gaze={4}
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -464,7 +465,13 @@ export function Sidebar({
|
||||
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-2 space-y-px">
|
||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||
{[
|
||||
...NAV_KEYS,
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
...(auth.state.mode === "server" && auth.state.user && auth.state.user.role !== "member"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
return (
|
||||
<NavLink
|
||||
|
||||
@@ -35,7 +35,15 @@ 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" };
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -123,6 +131,7 @@ const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
@@ -133,6 +142,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
<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>
|
||||
{q.plan && (
|
||||
<span
|
||||
data-testid="status-bar-quota-plan"
|
||||
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
|
||||
>
|
||||
{q.plan}
|
||||
</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")}
|
||||
@@ -146,11 +163,16 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w) => {
|
||||
{q.windows.map((w, i) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={w.label}>
|
||||
<Fragment key={`${w.group ?? ""}-${w.label}`}>
|
||||
{w.group && w.group !== q.windows[i - 1]?.group && (
|
||||
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
|
||||
{w.group}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fg-muted">
|
||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||
</span>
|
||||
|
||||
@@ -24,6 +24,7 @@ function today() {
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
sessions: {
|
||||
// 3 active sessions, 1 of them waiting on the user.
|
||||
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
|
||||
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
|
||||
active: true,
|
||||
label: "Work",
|
||||
account: "me@example.com",
|
||||
plan: "max",
|
||||
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||
windows: [
|
||||
{
|
||||
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
|
||||
windows: [],
|
||||
error: "EUPSTREAM",
|
||||
},
|
||||
{
|
||||
id: "antigravity",
|
||||
active: true,
|
||||
windows: [
|
||||
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
|
||||
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
|
||||
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: "c@example.com",
|
||||
plan: "pro",
|
||||
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
|
||||
},
|
||||
],
|
||||
checkedAt: "",
|
||||
}),
|
||||
@@ -214,8 +232,8 @@ describe("StatusBar", () => {
|
||||
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");
|
||||
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workmaxin use");
|
||||
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
expect(panel).toHaveTextContent("Could not read the quota");
|
||||
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
|
||||
expect(chips).toHaveTextContent("Antigravity50% used");
|
||||
expect(cursor).toHaveTextContent("c@example.compro");
|
||||
expect(cursor).toHaveTextContent("Month40%");
|
||||
expect(chips).toHaveTextContent("Cursor40% used");
|
||||
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
||||
await user.unhover(chips);
|
||||
await user.hover(panel);
|
||||
|
||||
@@ -307,6 +307,23 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists live sessions above ended ones, even an ended one active more recently", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [session("s-old", "Old one"), session("s-live", "Live one")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-session-s-live")).toHaveAttribute("data-live", "true")
|
||||
);
|
||||
const ids = screen.getAllByTestId(/^workspace-session-s-/).map((el) => el.dataset.testid);
|
||||
expect(ids).toEqual(["workspace-session-s-live", "workspace-session-s-old"]);
|
||||
});
|
||||
|
||||
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
|
||||
vi.mocked(api.run.list).mockImplementation(
|
||||
async () =>
|
||||
|
||||
@@ -13,34 +13,54 @@
|
||||
* 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.
|
||||
* Given `onBlock`, each changed block also gets the read-only diff's Undo /
|
||||
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
|
||||
* panel turns it off while the editor holds unsaved text, because git acts on
|
||||
* the file on disk.
|
||||
* Given `onBlock`, each changed block also gets the read-only diff's bar under
|
||||
* its last line — Undo (asks twice) and the block's commit checkbox
|
||||
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
|
||||
* the editor holds unsaved text, because git acts on the file on disk.
|
||||
* Given `blame`, a gutter left of the line numbers names each line's last
|
||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||
* a function that moves the cursor to the next / previous changed block.
|
||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||
* Chat chip above it, which hands the panel the selection's 1-based lines.
|
||||
* 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 { useTranslation } from "react-i18next";
|
||||
import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
|
||||
import {
|
||||
Compartment,
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
RangeSetBuilder,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Text,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
gutter,
|
||||
GutterMarker,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
showTooltip,
|
||||
type DecorationSet,
|
||||
type Tooltip,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { chunksOf, splitDiff } from "./splitDiff";
|
||||
import { highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneBlame } from "../../lib/types";
|
||||
|
||||
type Blame = Extract<LaneBlame, { available: true }>;
|
||||
|
||||
/**
|
||||
* True when a letter or digit of a line (`text`, starting at document offset
|
||||
@@ -76,7 +96,7 @@ function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const doc = view.state.doc;
|
||||
let last = 0;
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (let pos = from; pos <= to; ) {
|
||||
for (let pos = from; pos <= to;) {
|
||||
const line = doc.lineAt(pos);
|
||||
pos = line.to + 1;
|
||||
if (line.number <= last) continue;
|
||||
@@ -151,7 +171,52 @@ const diffColours = (lang: string) =>
|
||||
/** A changed block by line (1-based; a count of 0 is a pure insertion or
|
||||
* removal): what the panel matches against git's own change blocks. */
|
||||
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
|
||||
export type BlockAction = "keep" | "undo";
|
||||
export type BlockAction = "include" | "undo";
|
||||
|
||||
/** One Annotate cell: the line's commit date and author, its subject on hover. */
|
||||
class BlameMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly text: string,
|
||||
readonly title: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
override eq(other: BlameMarker) {
|
||||
return other.text === this.text && other.title === this.title;
|
||||
}
|
||||
override toDOM() {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = this.text;
|
||||
el.title = this.title;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
|
||||
* lines are matched by number, so unsaved edits above a line shift its
|
||||
* annotation until the file is saved and re-annotated. */
|
||||
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
|
||||
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
|
||||
return gutter({
|
||||
class: "cm-ah-blame",
|
||||
lineMarker(view, line) {
|
||||
const sha = shaAt(view.state, line.from);
|
||||
if (sha === undefined) return null;
|
||||
const c = sha ? blame.commits[sha] : undefined;
|
||||
return c
|
||||
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
|
||||
: new BlameMarker(notCommitted, notCommitted);
|
||||
},
|
||||
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
|
||||
domEventHandlers: {
|
||||
mousedown(view, line) {
|
||||
const sha = shaAt(view.state, line.from);
|
||||
if (sha) onClick(sha);
|
||||
return !!sha;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A chunk side's first line and line count. `to` is the start of the line
|
||||
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
|
||||
@@ -220,6 +285,26 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Add to Chat above a non-empty selection; `build` makes its DOM from the
|
||||
* selection's first and last 1-based line. A selection ending at a line's
|
||||
* start (a whole-line drag) does not take that line. */
|
||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||
return StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(tip, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return tip;
|
||||
const { from, to, empty } = tr.state.selection.main;
|
||||
if (empty) return null;
|
||||
const doc = tr.state.doc;
|
||||
const start = doc.lineAt(from).number;
|
||||
const last = doc.lineAt(to);
|
||||
const end = to === last.from && last.number > start ? last.number - 1 : last.number;
|
||||
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) };
|
||||
},
|
||||
provide: (f) => showTooltip.from(f),
|
||||
});
|
||||
}
|
||||
|
||||
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||
const UNDO =
|
||||
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||
@@ -234,6 +319,8 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
||||
const M = "&.cm-merge-b.cm-merge-b";
|
||||
const A = "&.cm-merge-a.cm-merge-a";
|
||||
const theme = EditorView.theme({
|
||||
// The chip draws its own box; drop its wrapper's default tooltip one.
|
||||
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "11px",
|
||||
@@ -278,6 +365,13 @@ const theme = EditorView.theme({
|
||||
background: "rgb(var(--surface-2))",
|
||||
},
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
".cm-ah-blame .cm-gutterElement": {
|
||||
color: "rgb(var(--fg-muted))",
|
||||
cursor: "pointer",
|
||||
fontSize: "10px",
|
||||
paddingRight: "6px",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
});
|
||||
|
||||
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
||||
@@ -304,6 +398,11 @@ export default function DiffEditor({
|
||||
onChange,
|
||||
onSave,
|
||||
onBlock,
|
||||
blockIncluded,
|
||||
blame,
|
||||
onBlameClick,
|
||||
navRef,
|
||||
onAddToChat,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
@@ -318,14 +417,24 @@ export default function DiffEditor({
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
/** Keep / Undo one changed block in git; absent = no bars. */
|
||||
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
|
||||
onBlock?: (action: BlockAction, block: EditorBlock) => void;
|
||||
/** Whether a block is ticked for the commit (its checkbox's state). */
|
||||
blockIncluded?: (block: EditorBlock) => boolean;
|
||||
/** Annotate: each line's last commit; absent = no gutter. */
|
||||
blame?: Blame;
|
||||
onBlameClick?: (sha: string) => void;
|
||||
/** Set to "go to the next (1) / previous (-1) changed block". */
|
||||
navRef?: { current: ((dir: 1 | -1) => void) | null };
|
||||
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
|
||||
onAddToChat?: (start: number, end: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave, onBlock, t });
|
||||
cb.current = { onChange, onSave, onBlock, t };
|
||||
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
|
||||
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
|
||||
const blameSlot = useRef(new Compartment()).current;
|
||||
|
||||
/** The diff view's review bar, as DOM: pinned to the visible right edge,
|
||||
* zero height so it never pushes lines (or a split view's alignment). */
|
||||
@@ -349,11 +458,13 @@ export default function DiffEditor({
|
||||
return b;
|
||||
};
|
||||
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
|
||||
const keep = chip(
|
||||
tr("changes.stageHunkShort"),
|
||||
tr("changes.stageHunk"),
|
||||
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
|
||||
);
|
||||
// The block's commit checkbox, a real one so it reads like the diff's.
|
||||
const include = bar.appendChild(document.createElement("input"));
|
||||
include.type = "checkbox";
|
||||
include.checked = cb.current.blockIncluded?.(block) ?? true;
|
||||
include.title = tr("changes.includeBlock");
|
||||
include.setAttribute("aria-label", tr("changes.includeBlock"));
|
||||
include.className = "h-3 w-3 cursor-pointer";
|
||||
// Two clicks, like every undo git cannot give back; disarms after 3 s.
|
||||
let timer = 0;
|
||||
const arm = (on: boolean) => {
|
||||
@@ -374,7 +485,7 @@ export default function DiffEditor({
|
||||
arm(true);
|
||||
timer = window.setTimeout(() => arm(false), 3000);
|
||||
};
|
||||
keep.onclick = () => cb.current.onBlock?.("keep", block);
|
||||
include.onchange = () => cb.current.onBlock?.("include", block);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -405,6 +516,28 @@ export default function DiffEditor({
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
}),
|
||||
blockBars(Text.of(original.split("\n")), buildBar),
|
||||
blameSlot.of([]),
|
||||
...(onAddToChat
|
||||
? [
|
||||
chatChip((start, end) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.dataset.testid = "lane-editor-add-to-chat";
|
||||
b.textContent = cb.current.t("changes.addToChat");
|
||||
b.className =
|
||||
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2";
|
||||
// Keep the editor's focus (and so its selection) through the click.
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||
// CodeMirror makes the returned element the `.cm-tooltip`, whose
|
||||
// themed rule above would strip the button's own border and fill.
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "cm-ah-chat";
|
||||
wrap.append(b);
|
||||
return wrap;
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
@@ -446,11 +579,47 @@ export default function DiffEditor({
|
||||
}
|
||||
v.focus();
|
||||
view.current = v;
|
||||
return destroy;
|
||||
if (navRef) {
|
||||
// The B side's chunks, unified or split: the cursor goes to the start
|
||||
// of the next / previous one and it scrolls into view.
|
||||
navRef.current = (dir) => {
|
||||
const ed = view.current;
|
||||
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
|
||||
if (!ed || !chunks.length) return;
|
||||
const at = ed.state.selection.main.head;
|
||||
const next =
|
||||
dir === 1
|
||||
? chunks.find((c) => c.fromB > at)
|
||||
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
|
||||
if (!next) return;
|
||||
ed.dispatch({
|
||||
selection: EditorSelection.cursor(next.fromB),
|
||||
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
|
||||
});
|
||||
ed.focus();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
if (navRef) navRef.current = null;
|
||||
destroy();
|
||||
};
|
||||
// One mount per view: the parent keys this component by file and shape.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Annotate on / off without remounting the view.
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({
|
||||
effects: blameSlot.reconfigure(
|
||||
blame
|
||||
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
|
||||
cb.current.onBlameClick?.(sha)
|
||||
)
|
||||
: []
|
||||
),
|
||||
});
|
||||
}, [blame, blameSlot]);
|
||||
|
||||
const bars = !!onBlock;
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({ effects: barsOn.of(bars) });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -247,7 +247,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
for (let i = 0; i < lines.length;) {
|
||||
if (lines[i]!.type === "ctx") {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -440,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
|
||||
* block's last line. A context rather than a prop because lines render from
|
||||
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (change: LaneDiffChange) => ReactNode;
|
||||
/** null = no bar; the anchors still mark each block for navigation. */
|
||||
actions: ((change: LaneDiffChange) => ReactNode) | null;
|
||||
changes: LaneDiffChange[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
@@ -457,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
|
||||
function HunkBar({ change }: { change: LaneDiffChange }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
if (!ctx?.actions) return null;
|
||||
const index = ctx.changes.indexOf(change);
|
||||
const total = ctx.changes.length;
|
||||
const nav =
|
||||
@@ -700,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["scrollTop", "scrollHeight", "clientHeight"],
|
||||
@@ -931,7 +931,9 @@ export function LaneDiffView({
|
||||
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
||||
// Provided without actions too: every block's first row carries its
|
||||
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
|
||||
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
|
||||
* pane above the ended ones) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
@@ -277,10 +278,12 @@ export function WorkspaceSessionList({
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
||||
);
|
||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||
// Live sessions first, ended ones under them — each group stays newest
|
||||
// first (the sort is stable over the server's order).
|
||||
const shown = (sessions ?? [])
|
||||
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
||||
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @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, and the per-block Undo / Keep bar.
|
||||
* editor's own diff classes on a real mount, the per-block Undo / Keep bar,
|
||||
* and the Add to Chat chip on a selection.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
@@ -166,7 +168,39 @@ describe("DiffEditor", () => {
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
|
||||
it("puts an Undo / Keep bar under each block, by line, in place of Revert", async () => {
|
||||
it("annotates each line in a gutter, and hands a click its commit", async () => {
|
||||
const onBlameClick = vi.fn();
|
||||
const props = {
|
||||
value: "a\nnew",
|
||||
original: "a",
|
||||
lang: "plain",
|
||||
label: "Edit",
|
||||
revertLabel: "Revert",
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onBlameClick,
|
||||
};
|
||||
const sha = "f".repeat(40);
|
||||
const blame = {
|
||||
available: true as const,
|
||||
path: "x",
|
||||
commits: {
|
||||
[sha]: { short: "fffffff", author: "Vi", date: "2026-10-01T00:00:00Z", summary: "s" },
|
||||
},
|
||||
lines: [sha, null],
|
||||
};
|
||||
const { container, rerender } = render(<DiffEditor {...props} />);
|
||||
expect(container.querySelector(".cm-ah-blame")).toBeNull();
|
||||
rerender(<DiffEditor {...props} blame={blame} />);
|
||||
await waitFor(() => expect(container.querySelector(".cm-ah-blame")).not.toBeNull());
|
||||
// The first element is CodeMirror's invisible width spacer.
|
||||
const cells = [...container.querySelectorAll(".cm-ah-blame .cm-gutterElement span")].slice(1);
|
||||
expect(cells.map((c) => c.textContent)).toEqual(["2026-10-01 Vi", "Not committed yet"]);
|
||||
fireEvent.mouseDown(cells[0]!.parentElement!);
|
||||
expect(onBlameClick).toHaveBeenCalledWith(sha);
|
||||
});
|
||||
|
||||
it("puts an Undo + commit-checkbox bar under each block, by line, in place of Revert", async () => {
|
||||
const onBlock = vi.fn();
|
||||
const props = {
|
||||
value: "a\nB\nc\nnew\ne",
|
||||
@@ -177,7 +211,9 @@ describe("DiffEditor", () => {
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(<DiffEditor {...props} onBlock={onBlock} />);
|
||||
const { container, rerender } = render(
|
||||
<DiffEditor {...props} onBlock={onBlock} blockIncluded={(b) => b.oldFrom === 2} />
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
|
||||
);
|
||||
@@ -188,8 +224,10 @@ describe("DiffEditor", () => {
|
||||
const [first, second] = [
|
||||
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
|
||||
];
|
||||
fireEvent.click(second!.querySelectorAll("button")[1]!);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("keep", {
|
||||
const boxes = [first, second].map((b) => b!.querySelector<HTMLInputElement>("input")!);
|
||||
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
|
||||
fireEvent.click(boxes[1]!);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("include", {
|
||||
oldFrom: 4,
|
||||
oldCount: 1,
|
||||
newFrom: 4,
|
||||
@@ -216,4 +254,40 @@ describe("DiffEditor", () => {
|
||||
"false"
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
|
||||
const onAddToChat = vi.fn();
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"a\nb\nc\nd"}
|
||||
original={"a\nb\nc\nd"}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
);
|
||||
const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
|
||||
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull();
|
||||
// b..c, ending at the start of d: d is not taken.
|
||||
view.dispatch({ selection: { anchor: 2, head: 6 } });
|
||||
const chip = await waitFor(() => {
|
||||
const b = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="lane-editor-add-to-chat"]'
|
||||
);
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so
|
||||
// the button must not be the `.cm-tooltip` itself.
|
||||
expect(chip.classList.contains("cm-tooltip")).toBe(false);
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
* once, the range toggle changes which comparison is requested, History opens
|
||||
* one commit, and Files quick-opens a whole file. Also pins the per-file git
|
||||
* writes — including discard needing two clicks before the server hears
|
||||
* `confirm` — and the degrade for a cwd git cannot read.
|
||||
* `confirm` — the JetBrains commit (ticked files, Changes ticked and
|
||||
* Unversioned Files not by default, Commit and Push, Add to VCS / .gitignore,
|
||||
* a change block's own checkbox making a hunk-level commit), a file's
|
||||
* history, Annotate, next/previous file, and the degrade for a cwd git
|
||||
* cannot read.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -70,6 +74,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitLs: vi.fn(),
|
||||
gitTree: vi.fn(),
|
||||
gitTreeAll: vi.fn(),
|
||||
gitBlame: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -125,6 +130,11 @@ async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||
}
|
||||
|
||||
/** The overview row's checkbox for one path. */
|
||||
function tickOf(path: string) {
|
||||
return screen.getByRole("checkbox", { name: `Include ${path} in the commit` });
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
@@ -133,7 +143,6 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -280,60 +289,36 @@ describe("LaneChangesPanel", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages one file and asks the server for that path alone", async () => {
|
||||
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// Both rows offer it (one modified, one untracked); the first is the
|
||||
// modified file the assertion below names.
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stage!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
});
|
||||
|
||||
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)" }));
|
||||
// Only the untracked row offers it: a tracked file is already in VCS.
|
||||
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||
expect(more).toHaveLength(0);
|
||||
expect(tickOf("notes.txt")).not.toBeChecked();
|
||||
await user.click(add!);
|
||||
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([]);
|
||||
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
render(<LaneChangesPanel 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");
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
|
||||
expect(group).toBePartiallyChecked();
|
||||
await user.click(group); // some ticked -> untick all
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(group); // -> tick all
|
||||
expect(tickOf("docs/API.md")).toBeChecked();
|
||||
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||
it("lists changes as a folder tree: a folder folds, and its box unticks every file in it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
@@ -341,16 +326,15 @@ describe("LaneChangesPanel", () => {
|
||||
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");
|
||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||
name: "Tick every file in server/lib",
|
||||
});
|
||||
expect(box).toBeChecked();
|
||||
await user.click(box);
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
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"],
|
||||
});
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
||||
@@ -373,7 +357,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||
await user.click(stage!);
|
||||
// The header's counts moved with the index: waiting out its 30s poll
|
||||
// would leave them wrong beside the list that just changed.
|
||||
@@ -420,43 +404,61 @@ describe("LaneChangesPanel", () => {
|
||||
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("stages every file the index lacks in one call, from above the list", async () => {
|
||||
it("commits exactly the ticked files: Changes ticked, Unversioned Files not, by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const actions = screen.getByTestId("lane-changes-actions");
|
||||
// Commit box and Stage all come BEFORE the file list.
|
||||
// The commit box comes BEFORE the file list.
|
||||
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
|
||||
// The already-staged docs/API.md is not sent again.
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
await user.click(tickOf("notes.txt"));
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: picked");
|
||||
await user.click(screen.getByRole("button", { name: "Commit (2)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: picked",
|
||||
paths: ["server/lib/git-ops.js", "notes.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
it("Commit and Push commits the ticked files, then pushes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "feat: ship");
|
||||
await user.click(screen.getByRole("button", { name: "Commit and Push" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(api.lanes.gitAction).mock.calls).toEqual([
|
||||
[7, "commit", { message: "feat: ship", paths: ["server/lib/git-ops.js", "docs/API.md"] }],
|
||||
[7, "push", {}],
|
||||
]);
|
||||
});
|
||||
|
||||
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(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7, [
|
||||
"server/lib/git-ops.js",
|
||||
"docs/API.md",
|
||||
]);
|
||||
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
it("lists tracked changes apart from Unversioned Files, staged or not", 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).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
|
||||
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
|
||||
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(tracked).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||
@@ -627,21 +629,21 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByTitle("server/lib/git-ops.js");
|
||||
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
||||
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
|
||||
|
||||
// Commit File: that file alone is ticked, and the message box has focus.
|
||||
await right();
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
vi.mocked(api.lanes.gitAction).mockClear();
|
||||
await user.click(menu().getByText("Commit File…"));
|
||||
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
|
||||
expect(tickOf("docs/API.md")).not.toBeChecked();
|
||||
expect(screen.getByLabelText("Commit message")).toHaveFocus();
|
||||
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
|
||||
|
||||
await right();
|
||||
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
||||
@@ -654,10 +656,123 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
|
||||
await right();
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
|
||||
);
|
||||
await user.click(menu().getByText("Copy relative path"));
|
||||
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
|
||||
|
||||
// Copy as Patch: that file's diff, read for its path, as a unified patch.
|
||||
vi.mocked(api.lanes.changes).mockResolvedValueOnce(
|
||||
changes({
|
||||
files: [{ ...changes().files![0]!, hunks: [HUNK] } as never],
|
||||
})
|
||||
);
|
||||
await right();
|
||||
await user.click(menu().getByText("Copy as Patch to Clipboard"));
|
||||
await waitFor(async () =>
|
||||
expect(await navigator.clipboard.readText()).toBe(
|
||||
[
|
||||
"diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js",
|
||||
"--- a/server/lib/git-ops.js",
|
||||
"+++ b/server/lib/git-ops.js",
|
||||
"@@ -1,2 +1,3 @@",
|
||||
" keep",
|
||||
"-old line",
|
||||
"+new line",
|
||||
"",
|
||||
].join("\n")
|
||||
)
|
||||
);
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("right-click on an unversioned file offers Add to VCS and Add to .gitignore", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByTitle("notes.txt");
|
||||
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(menu().getByText("Add to .gitignore"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "ignore", { paths: ["notes.txt"] });
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
expect(menu().getByText("Add to VCS")).toBeInTheDocument();
|
||||
// A tracked file is already in VCS, and git would not ignore it anyway.
|
||||
await user.keyboard("{Escape}");
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: within(list).getByTitle("docs/API.md"),
|
||||
});
|
||||
expect(menu().queryByText("Add to .gitignore")).toBeNull();
|
||||
expect(menu().queryByText("Add to VCS")).toBeNull();
|
||||
});
|
||||
|
||||
it("Show History lists one file's commits and opens the file at a commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: within(list).getByTitle("server/lib/git-ops.js"),
|
||||
});
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 50,
|
||||
file: "server/lib/git-ops.js",
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
|
||||
"server/lib/git-ops.js"
|
||||
);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
|
||||
range: "commit",
|
||||
sha: "a".repeat(40),
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("walks to the next file from an open one, in list order", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
await user.click(await screen.findByRole("button", { name: "Next file" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
|
||||
range: "work",
|
||||
path: "notes.txt",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Previous file" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("annotates the open file with who last changed each line", async () => {
|
||||
vi.mocked(api.lanes.gitBlame).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
commits: {},
|
||||
lines: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
await user.click(await screen.findByRole("button", { name: "Annotate" }));
|
||||
expect(api.lanes.gitBlame).toHaveBeenCalledWith(7, "server/lib/git-ops.js");
|
||||
expect(screen.getByRole("button", { name: "Annotate" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true"
|
||||
);
|
||||
});
|
||||
|
||||
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
||||
@@ -851,7 +966,10 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByTestId("lane-changes-amend"));
|
||||
await user.click(screen.getByRole("button", { name: "Amend" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", { sha: "aaaaaaa" })
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", {
|
||||
sha: "aaaaaaa",
|
||||
paths: ["server/lib/git-ops.js", "docs/API.md"],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -938,7 +1056,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
|
||||
it("colours the Files tree like JetBrains: modified blue, unversioned orange, added green", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -948,6 +1066,8 @@ describe("LaneChangesPanel", () => {
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
|
||||
{ name: "keep.txt", path: "keep.txt", dir: false },
|
||||
{ name: "add.txt", path: "add.txt", dir: false, status: "A " },
|
||||
{ name: "gone.txt", path: "gone.txt", dir: false, status: " D" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
@@ -958,9 +1078,12 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
expect(await row("a.txt")).toHaveClass("text-diff-mod");
|
||||
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
|
||||
expect(await row("new.txt")).toHaveClass("text-status-success");
|
||||
expect(await row("new.txt")).toHaveClass("text-kept");
|
||||
expect(await row("add.txt")).toHaveClass("text-status-success");
|
||||
expect(await row("gone.txt")).toHaveClass("text-fg-muted");
|
||||
expect(await row("src")).toHaveClass("text-diff-mod");
|
||||
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
|
||||
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
|
||||
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
|
||||
@@ -1069,18 +1192,6 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
|
||||
});
|
||||
|
||||
it("unstages every staged file in one call, in the same pinned bar as Stage all and commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const row = screen.getByTestId("lane-changes-actions");
|
||||
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
|
||||
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
|
||||
});
|
||||
|
||||
it("switches the diff to side-by-side", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -1157,22 +1268,14 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("commits the index from the overview, never the whole tree", async () => {
|
||||
it("cannot commit with nothing ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// The box exists only because a file is staged — with a clean index there
|
||||
// is nothing this button could mean.
|
||||
const box = await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
|
||||
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
|
||||
|
||||
expect(box).toBeInTheDocument();
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: the staged half",
|
||||
only: "staged",
|
||||
});
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
|
||||
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("drops a half-typed commit message on a lane switch", async () => {
|
||||
@@ -1185,24 +1288,10 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByLabelText("Commit message")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hides the commit box when nothing is staged", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
it("hides the commit box when there is nothing to commit", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await screen.findByText("Nothing uncommitted.");
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1292,22 +1381,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
};
|
||||
|
||||
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
// Two hunks on the tracked file; the untracked one has none to act on.
|
||||
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
|
||||
expect(stage).toHaveLength(2);
|
||||
await user.click(stage[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -9,2 +10,3 @@",
|
||||
block: 0,
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
|
||||
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
@@ -1350,13 +1424,35 @@ describe("LaneChangesPanel", () => {
|
||||
expect.stringContaining("1 of 2"),
|
||||
expect.stringContaining("2 of 2"),
|
||||
]);
|
||||
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,4 +1,5 @@",
|
||||
block: 1,
|
||||
context: 3,
|
||||
});
|
||||
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
|
||||
expect(boxes).toHaveLength(2);
|
||||
await user.click(boxes[1]!);
|
||||
expect(boxes[0]).toBeChecked();
|
||||
expect(boxes[1]).not.toBeChecked();
|
||||
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
// The second hunk's only block unticked: the file is partial.
|
||||
await user.click(
|
||||
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||
);
|
||||
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: half");
|
||||
await user.click(screen.getByRole("button", { name: "Commit (1)" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: half",
|
||||
paths: [],
|
||||
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("hangs one review bar under each actionable hunk, counting them", async () => {
|
||||
@@ -1398,14 +1494,18 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stages a whole file from its review header without folding it", async () => {
|
||||
it("unticks a whole file from its review header without folding it", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stageFile!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
// Every block of it follows the file.
|
||||
for (const box of screen.getAllByRole("checkbox", {
|
||||
name: "Include this change in the commit",
|
||||
}))
|
||||
expect(box).not.toBeChecked();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
@@ -1615,7 +1715,7 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||
await user.click(stage!);
|
||||
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
@@ -1732,6 +1832,8 @@ describe("a merge in progress (issue #45)", () => {
|
||||
// Not offered as an ordinary stage: that would mark it resolved blind.
|
||||
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
||||
// A partial commit cannot finish a merge: Complete is the way out.
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
|
||||
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* header: drag it to move the pane (drop on another pane's edge to split
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
|
||||
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
||||
* session there (X, `onStop`) without taking it out first. The grid shows
|
||||
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
||||
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
||||
* shows its state dot, as its tab does.
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
Minimize2,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -309,8 +311,11 @@ export function PaneGrid({
|
||||
toolLabel,
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
onStop,
|
||||
}: {
|
||||
layout: PaneLayout;
|
||||
/** Stops the session in `slot` (the header's X). */
|
||||
onStop?: (slot: number) => void;
|
||||
/** A live AI run's state for its header dot (none for a shell). */
|
||||
stateOf?: (r: RunHandle) => RunState | null;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
@@ -514,6 +519,18 @@ export function PaneGrid({
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{run && onStop && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-stop-${slot}`}
|
||||
title={t("panes.stop")}
|
||||
aria-label={t("panes.stop")}
|
||||
onClick={() => onStop(slot)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -557,6 +574,11 @@ export function PaneGrid({
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
{onStop && runOf(paneCtx.menu.item) && (
|
||||
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||
{t("panes.stop")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
{dividers.map((d) => (
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
|
||||
@@ -217,7 +218,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// under the canvas renderer (which only stops a glyph from shoving its
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too.
|
||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||
// Appearance may swap it for the rounded Maple Mono (also complete) or
|
||||
// the browser's own `monospace` (lib/terminalFont.ts).
|
||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||
// it on a PTY, where termios already does that translation for the streams
|
||||
@@ -229,7 +232,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
});
|
||||
@@ -510,6 +513,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
),
|
||||
[runId]
|
||||
);
|
||||
// The Workspace's focused pane takes those pastes even when the focus moved
|
||||
// without a click inside the terminal (a tab, Alt+1–4, the sidebar).
|
||||
useEffect(() => {
|
||||
if (autoFocus) notePaneFocus(runId);
|
||||
}, [autoFocus, runId]);
|
||||
|
||||
const runFind = (backwards = false) => {
|
||||
const q = find ?? "";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { pasteToRun } from "../../../lib/panePaste";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
const onDataHandlers: Array<(d: string) => void> = [];
|
||||
@@ -311,6 +311,25 @@ describe("TerminalView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("takes the workspace's paste once it is the focused pane, clicked into or not", () => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800);
|
||||
try {
|
||||
const both = (focus: number) => (
|
||||
<>
|
||||
<TerminalView runId="agenthub-lane-9" wsBaseUrl="ws://x" autoFocus={focus === 0} />
|
||||
<TerminalView runId="agenthub-lane-9-1" wsBaseUrl="ws://x" autoFocus={focus === 1} />
|
||||
</>
|
||||
);
|
||||
const { rerender } = render(both(0));
|
||||
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9");
|
||||
// Alt+2, a tab click: the Workspace moves the focus, the DOM may not.
|
||||
rerender(both(1));
|
||||
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9-1");
|
||||
} finally {
|
||||
width.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes the keyboard as soon as it mounts", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// opening a lane went nowhere until the user clicked the pane.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), 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
|
||||
@@ -11,6 +12,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
@@ -18,6 +20,12 @@ import { PetSettings } from "./PetSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
readTerminalFont,
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -65,6 +73,11 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
const ok = await confirmDialog({
|
||||
@@ -102,6 +115,37 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="terminal-font"
|
||||
title={t("terminalFont.title")}
|
||||
description={t("terminalFont.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("terminalFont.title")}
|
||||
value={termFont}
|
||||
onChange={pickTermFont}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
value: f,
|
||||
label: t(`terminalFont.names.${f}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
data-testid="terminal-font-preview"
|
||||
aria-label={t("terminalFont.preview")}
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<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
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
* whether the dashboard starts at boot/login through the per-user service
|
||||
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
||||
* stops the running server.
|
||||
* stops the running server. Beside it, **Quit AgentHub** (issue #65, 9router's
|
||||
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
|
||||
* stops the server, which stays stopped until `agenthub start` — or the next
|
||||
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AutostartStatus } from "../../lib/api";
|
||||
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function AutostartSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -36,6 +41,39 @@ export function AutostartSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const [tray, setTray] = useState<TrayStatus | null>(null);
|
||||
useEffect(() => {
|
||||
api.settings.tray
|
||||
.get()
|
||||
.then(setTray)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const changeTray = async (enabled: boolean) => {
|
||||
try {
|
||||
setTray(await api.settings.tray.set(enabled));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const [quitting, setQuitting] = useState(false);
|
||||
const quit = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("quit.confirmTitle"),
|
||||
message: t(status?.enabled ? "quit.confirmMessageAutostart" : "quit.confirmMessage"),
|
||||
confirmLabel: t("quit.button"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setQuitting(true);
|
||||
try {
|
||||
await api.settings.quit();
|
||||
toast.success(t("quit.done"));
|
||||
} catch (err) {
|
||||
setQuitting(false);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const note = !status
|
||||
? undefined
|
||||
: !status.available
|
||||
@@ -53,6 +91,28 @@ export function AutostartSettings() {
|
||||
disabled={!status?.available || status.foreign || saving}
|
||||
onChange={(v) => void change(v)}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("tray.label")}
|
||||
description={tray && !tray.available ? t("tray.unavailable") : t("tray.desc")}
|
||||
checked={!!tray?.available && tray.enabled}
|
||||
disabled={!tray?.available}
|
||||
onChange={(v) => void changeTray(v)}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("quit.label")}
|
||||
description={t("quit.desc")}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
data-testid="quit-agenthub"
|
||||
disabled={quitting}
|
||||
onClick={() => void quit()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
>
|
||||
{t("quit.button")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* @description The dashboard's own settings that belong to Claude Code, shown
|
||||
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
|
||||
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
|
||||
* plugin provides them), and a link to the Claude table on the Pricing page.
|
||||
* Changing CLAUDE_HOME asks first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,12 +15,17 @@ import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from
|
||||
import { api } from "../../lib/api";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { HomeDirSetting } from "./HomeDirSetting";
|
||||
import { PricingSettings } from "./PricingSettings";
|
||||
import { PricingLink } from "./PricingSettings";
|
||||
|
||||
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
type HooksInfo = {
|
||||
installed: boolean;
|
||||
path: string;
|
||||
hooks: Record<string, boolean>;
|
||||
source?: "plugin";
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
||||
* Claude Code's AgentHub settings: CLAUDE_HOME, hook status and the pricing link.
|
||||
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
||||
* the config it lists, which lives under that home).
|
||||
* @returns The three sections, each with its own id.
|
||||
@@ -205,13 +211,17 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
{hooks.source === "plugin" ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("hooks.plugin")}</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
<PricingLink vendor="claude" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,18 @@
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
DollarSign,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
@@ -191,6 +202,29 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
|
||||
export type PricingVendor = "claude" | "openai" | "google";
|
||||
|
||||
/**
|
||||
* A tool page's pointer to its vendor's table on the one Pricing page — the
|
||||
* table itself lives only there, so a price is never shown (or edited) twice.
|
||||
*/
|
||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="card flex items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<DollarSign className="h-4 w-4 text-fg-muted" />
|
||||
{t("pricingLink.title")}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||
</div>
|
||||
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
|
||||
{t("pricingLink.open")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Every mounted table's `load`: a change in one reloads them all, and loads
|
||||
// fired together share one request.
|
||||
const tables = new Set<() => void>();
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* @file UsersSettings.tsx
|
||||
* @description Settings → Users, server mode only and only for an admin or
|
||||
* analyst (docs/SERVER-MODE.md): every account with its role, state,
|
||||
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
|
||||
* (`GET /api/users`), and the audit log of staff reads of members' sessions
|
||||
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
||||
* `agenthub users …` on the host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function UsersSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch((e: Error) => setError(e.message));
|
||||
api.users
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
||||
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{u.role}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
||||
{audit?.length === 0 && (
|
||||
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
|
||||
)}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @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>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, 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>
|
||||
*/
|
||||
|
||||
@@ -73,8 +73,8 @@ describe("AppearanceSettings", () => {
|
||||
it("the System mode is checked after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("radio", { name: /System/ }));
|
||||
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
|
||||
await user.click(screen.getByRole("radio", { name: "System" }));
|
||||
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
});
|
||||
|
||||
@@ -84,4 +84,21 @@ describe("AppearanceSettings", () => {
|
||||
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
|
||||
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
||||
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
|
||||
await user.click(within(group).getByRole("radio", { name: "System font" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
|
||||
* first, says when start-at-login will bring the dashboard back, and only a
|
||||
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
|
||||
* switch, which goes straight to PUT /api/settings/tray.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { AutostartSettings } from "../AutostartSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import * as confirm from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
settings: {
|
||||
autostart: { get: vi.fn(), set: vi.fn() },
|
||||
tray: { get: vi.fn(), set: vi.fn() },
|
||||
quit: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const status = { available: true, kind: "systemd", enabled: true, foreign: false };
|
||||
const trayOff = { available: true, enabled: false, running: false };
|
||||
|
||||
describe("Quit AgentHub", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
|
||||
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
|
||||
vi.mocked(api.settings.quit).mockReset().mockResolvedValue({ ok: true, killed: 0 });
|
||||
});
|
||||
|
||||
it("does nothing when the ask is cancelled", async () => {
|
||||
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
|
||||
render(<AutostartSettings />);
|
||||
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
|
||||
expect(api.settings.quit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quits once confirmed, warning that start-at-login brings it back", async () => {
|
||||
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
|
||||
render(<AutostartSettings />);
|
||||
await waitFor(() => expect(api.settings.autostart.get).toHaveBeenCalled());
|
||||
await screen.findByRole("switch", { checked: true });
|
||||
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||
await waitFor(() => expect(api.settings.quit).toHaveBeenCalledTimes(1));
|
||||
expect(ask.mock.calls[0]![0].message).toMatch(/next boot\/login/);
|
||||
expect(screen.getByTestId("quit-agenthub")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("turns the status-area icon on from its switch", async () => {
|
||||
vi.mocked(api.settings.tray.set).mockResolvedValue({
|
||||
...trayOff,
|
||||
enabled: true,
|
||||
running: true,
|
||||
});
|
||||
render(<AutostartSettings />);
|
||||
await waitFor(() => expect(api.settings.tray.get).toHaveBeenCalled());
|
||||
const icon = screen.getAllByRole("switch")[1]!;
|
||||
await waitFor(() => expect(icon).toBeEnabled());
|
||||
fireEvent.click(icon);
|
||||
await waitFor(() => expect(api.settings.tray.set).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(icon).toBeChecked());
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,24 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.test.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||
* @description Settings → Claude Code → AgentHub: the hook status with
|
||||
* Reinstall (named as the plugin's when the plugin provides the hooks),
|
||||
* CLAUDE_HOME, and a link to the Claude table on the Pricing page — the
|
||||
* table itself is shown only there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
|
||||
|
||||
const render = (ui: ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
|
||||
function confirmYes() {
|
||||
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
|
||||
@@ -36,24 +40,9 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const RULE = {
|
||||
model_pattern: "claude-opus-5-5%",
|
||||
display_name: "Claude Opus 5.5",
|
||||
input_per_mtok: 4,
|
||||
output_per_mtok: 20,
|
||||
cache_read_per_mtok: 0.2,
|
||||
cache_write_per_mtok: 5,
|
||||
cache_write_1h_per_mtok: 8,
|
||||
fast_input_per_mtok: 8,
|
||||
fast_output_per_mtok: 40,
|
||||
updated_at: "2026-09-28 10:00:00",
|
||||
} as ModelPricing;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ pricing: [RULE] });
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
|
||||
vi.mocked(api.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -76,10 +65,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("ClaudeAgentHubSettings", () => {
|
||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||
it("renders hook status and CLAUDE_HOME, and links to the Claude prices", async () => {
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/pricing#pricing-claude"
|
||||
);
|
||||
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
|
||||
expect(api.pricing.list).not.toHaveBeenCalled();
|
||||
for (const id of ["hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
@@ -121,14 +115,19 @@ describe("ClaudeAgentHubSettings", () => {
|
||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||
});
|
||||
|
||||
it("resets pricing only on the second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
|
||||
vi.mocked(api.settings.info).mockResolvedValue({
|
||||
hooks: {
|
||||
installed: true,
|
||||
source: "plugin",
|
||||
path: "/home/u/.claude/settings.json",
|
||||
hooks: { UserPromptSubmit: true },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||
render(<ClaudeAgentHubSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||
const note = await screen.findByText(/provided by the agenthub plugin/i);
|
||||
const hooks = note.closest("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
|
||||
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ export const i18nReady = i18n
|
||||
"alerts",
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -58,11 +58,7 @@
|
||||
"changes.collapseContext": "Show fewer surrounding lines",
|
||||
"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.suggestMessage": "Suggest a commit message with AI (Claude, ticked files)",
|
||||
"changes.conflicted": "conflict",
|
||||
"changes.discard": "Discard this file's changes",
|
||||
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
||||
@@ -79,6 +75,7 @@
|
||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||
"changes.commentSend": "Paste into pane",
|
||||
"changes.addToChat": "Add to Chat",
|
||||
"changes.commentCancel": "Cancel",
|
||||
"changes.commentNoPane": "No terminal of this workspace is on screen — open its session (or narrow the Changes panel) first",
|
||||
"changes.layout.split": "Side-by-side",
|
||||
@@ -105,13 +102,7 @@
|
||||
"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?",
|
||||
"changes.stagedGroup": "Staged Changes",
|
||||
"changes.unstagedGroup": "Changes",
|
||||
"changes.stage": "Stage this file",
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
@@ -236,10 +227,8 @@
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"changes.stageHunk": "Stage this hunk",
|
||||
"changes.undoHunk": "Undo this hunk",
|
||||
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
|
||||
"changes.stageHunkShort": "Keep",
|
||||
"changes.undoHunkShort": "Undo",
|
||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||
"changes.edit": "Edit",
|
||||
@@ -278,14 +267,14 @@
|
||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
||||
"gitBar.merging": "A {{op}} is in progress: finish or abort it first",
|
||||
"changes.conflictsGroup": "Merge conflicts",
|
||||
"changes.mergeBanner": "A {{op}} is in progress \u2014 {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "A {{op}} is in progress \u2014 every conflict is resolved",
|
||||
"changes.mergeBanner": "A {{op}} is in progress — {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "A {{op}} is in progress — every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete {{op}}",
|
||||
"changes.mergeAbort": "Abort {{op}}",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
||||
"changes.discardShortConfirm": "Discard?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendHint": "Fold the staged changes into the last commit (only before it is pushed). Leave the message empty to keep the old one.",
|
||||
"changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.",
|
||||
"changes.amendPlaceholder": "New message (empty = keep the old one)",
|
||||
"changes.historyOf": "History of branch",
|
||||
"changes.cherryPick": "Cherry-pick onto {{branch}}",
|
||||
@@ -435,6 +424,7 @@
|
||||
"panes.splitNew.right": "Split right with a new terminal",
|
||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||
"panes.closeShort": "Move to own tab",
|
||||
"panes.stop": "Stop session",
|
||||
"changes.copyPath": "Copy relative path",
|
||||
"changes.viewCommit": "View commit",
|
||||
"changes.copySha": "Copy commit SHA",
|
||||
@@ -475,5 +465,30 @@
|
||||
"runtime.listening": "listening",
|
||||
"runtime.notListening": "not listening",
|
||||
"replay.pasted": "That session was already open in a tab — your message is pasted there; press Enter to send it.",
|
||||
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}"
|
||||
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}",
|
||||
"changes.changesGroup": "Changes",
|
||||
"changes.unversionedGroup": "Unversioned Files",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit and Push",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
"changes.tickFolder": "Tick every file in {{path}}",
|
||||
"changes.commitFile": "Commit File…",
|
||||
"changes.jumpToSource": "Jump to Source",
|
||||
"changes.copyPatch": "Copy as Patch to Clipboard",
|
||||
"changes.noPatch": "This file has no text diff to copy",
|
||||
"changes.addToVcs": "Add to VCS",
|
||||
"changes.addToGitignore": "Add to .gitignore",
|
||||
"changes.includeBlock": "Include this change in the commit",
|
||||
"changes.prevDiff": "Previous difference (Shift+F7)",
|
||||
"changes.nextDiff": "Next difference (F7)",
|
||||
"changes.prevFile": "Previous file",
|
||||
"changes.nextFile": "Next file",
|
||||
"changes.showHistory": "Show History",
|
||||
"changes.historyOfFile": "History of {{path}}",
|
||||
"changes.historyWholeBranch": "Whole branch",
|
||||
"changes.annotate": "Annotate",
|
||||
"changes.noBlame": "Nothing to annotate: this file has no committed history yet",
|
||||
"changes.notCommitted": "Not committed yet"
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"quotaActive": "in use",
|
||||
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
@@ -94,5 +95,6 @@
|
||||
"resizeSidebar": "Resize sidebar (double-click to reset)",
|
||||
"closeMenu": "Close menu",
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now"
|
||||
"now": "now",
|
||||
"users": "Users"
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@
|
||||
"incomplete": "Hooks incomplete",
|
||||
"reinstall": "Reinstall Hooks",
|
||||
"success": "Hooks installed successfully",
|
||||
"failed": "Hook installation failed"
|
||||
"failed": "Hook installation failed",
|
||||
"plugin": "Provided by the agenthub plugin (its plugin.json), not settings.json"
|
||||
},
|
||||
"lanesSection": {
|
||||
"title": "Workspaces",
|
||||
@@ -311,16 +312,51 @@
|
||||
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||
},
|
||||
"tray": {
|
||||
"label": "Show AgentHub icon in the status area",
|
||||
"desc": "An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.",
|
||||
"unavailable": "Not available here: needs a graphical desktop session, and is off in server mode."
|
||||
},
|
||||
"quit": {
|
||||
"label": "Quit AgentHub",
|
||||
"desc": "Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run `agenthub start` to bring it back.",
|
||||
"button": "Quit",
|
||||
"confirmTitle": "Quit AgentHub?",
|
||||
"confirmMessage": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`.",
|
||||
"confirmMessageAutostart": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`, or until the next boot/login (start automatically is on).",
|
||||
"done": "AgentHub is stopping. Run `agenthub start` to start it again."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Server runtime information.",
|
||||
"title": "System",
|
||||
"release": "Release",
|
||||
"uiBuild": "UI build v{{version}} (rebuild client to match)",
|
||||
"uptime": "Uptime",
|
||||
"nodejs": "Node.js",
|
||||
"platform": "Platform",
|
||||
"wsClients": "WS Clients",
|
||||
"loadingInfo": "Loading server info..."
|
||||
"wsClients": "Live connections",
|
||||
"wsClientsDesc": "Browser tabs and tools receiving live updates",
|
||||
"loadingInfo": "Loading server info...",
|
||||
"server": "Server",
|
||||
"cpu": "CPU",
|
||||
"cpuValue": "{{count}} cores · load {{load}}",
|
||||
"memory": "Process memory",
|
||||
"memoryValue": "{{rss}} RSS · heap {{used}} of {{limit}}",
|
||||
"systemMemory": "System memory",
|
||||
"systemMemoryValue": "{{used}} used of {{total}}",
|
||||
"install": "Installation",
|
||||
"installKind": {
|
||||
"plugin": "Claude Code plugin",
|
||||
"checkout": "Git checkout"
|
||||
},
|
||||
"latest": "Latest release",
|
||||
"upToDate": "Up to date",
|
||||
"updateAvailable": "Update available",
|
||||
"unknown": "Unknown",
|
||||
"database": "Database",
|
||||
"dbFile": "Database file",
|
||||
"journal": "Journal mode",
|
||||
"activity": "Events recorded",
|
||||
"activityValue": "{{m5}} in 5 min · {{m15}} in 15 min · {{h1}} in 1 h"
|
||||
},
|
||||
"tables": {
|
||||
"sessions": "sessions",
|
||||
@@ -764,9 +800,7 @@
|
||||
"group": {
|
||||
"general": "General",
|
||||
"agents": "Agents",
|
||||
"routing": "Routing",
|
||||
"git": "Git",
|
||||
"cost": "Cost"
|
||||
"data": "Data"
|
||||
},
|
||||
"section": {
|
||||
"appearance": {
|
||||
@@ -800,37 +834,25 @@
|
||||
"title": "Pricing",
|
||||
"description": "Model prices for every vendor, used to estimate cost. Rates per million tokens (USD); % is a wildcard in a pattern; a model's introductory rate is shown in violet."
|
||||
},
|
||||
"gitCredentials": {
|
||||
"title": "Git credentials",
|
||||
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push."
|
||||
},
|
||||
"worktrees": {
|
||||
"title": "Worktrees",
|
||||
"description": "Which untracked git worktrees a workspace's Add folder suggests."
|
||||
},
|
||||
"data": {
|
||||
"title": "Data",
|
||||
"description": "Cost and database overview, import, remote sources, export, cleanup and the danger zone."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Server version, uptime and runtime."
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Snapshots",
|
||||
"description": "Frozen, read-only copies of sessions, each behind its own link."
|
||||
"title": "Database",
|
||||
"description": "Total cost, database overview, import, remote sources, export, cleanup and the danger zone."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"description": "Analytics summaries written on a schedule, as HTML or JSON."
|
||||
"title": "Snapshots & reports",
|
||||
"description": "Read-only session snapshots behind their own links, and analytics summaries written on a schedule."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Accounts, their usage and the audit log."
|
||||
"system": {
|
||||
"title": "System",
|
||||
"description": "Version, install, start at login, server runtime and the database file."
|
||||
},
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push, and which worktrees Add folder suggests."
|
||||
}
|
||||
},
|
||||
"backPrevious": "Back",
|
||||
"unknownSection": "There is no “{{section}}” settings page — showing General instead."
|
||||
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search settings",
|
||||
@@ -856,6 +878,18 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved in this browser only.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
"maple": "Maple Mono (rounded)",
|
||||
"system": "System font"
|
||||
},
|
||||
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Copy host",
|
||||
"copyUrl": "Copy endpoint",
|
||||
@@ -947,20 +981,9 @@
|
||||
"imported": "Imported {{count}} preference(s). Reloading…",
|
||||
"badFile": "That file is not an AgentHub preferences file."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
|
||||
"name": "User",
|
||||
"role": "Role",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"disabled": "disabled",
|
||||
"tokens": "{{n}} tokens",
|
||||
"manageHint": "Add, disable or change a user on the host: agenthub users …",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
|
||||
"auditEmpty": "No reads recorded yet."
|
||||
"pricingLink": {
|
||||
"title": "Model pricing",
|
||||
"description": "Prices for every vendor's models live on one page.",
|
||||
"open": "Open Pricing"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"title": "Users",
|
||||
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
|
||||
"name": "User",
|
||||
"role": "Role",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"disabled": "disabled",
|
||||
"you": "you",
|
||||
"tokens": "{{n}} tokens",
|
||||
"actions": "Actions",
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"analyst": "Analyst",
|
||||
"member": "Member"
|
||||
},
|
||||
"roleHint": {
|
||||
"admin": "Everything, including this server's configuration and every user's data",
|
||||
"analyst": "Reads everything; no terminal, no changes",
|
||||
"member": "Only their own workspaces"
|
||||
},
|
||||
"add": "Add user",
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||
"password": "Password",
|
||||
"passwordHint": "At least 8 characters",
|
||||
"newPassword": "New password",
|
||||
"changeRole": "Change role",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"resetPassword": "Reset password",
|
||||
"assignWorkspace": "Give a workspace",
|
||||
"workspace": "Workspace",
|
||||
"noOwner": "no owner",
|
||||
"ownedBy": "owned by {{name}}",
|
||||
"noWorkspaces": "There are no workspaces yet.",
|
||||
"yourPassword": "Your password",
|
||||
"yourPasswordHint": "Type your own password to confirm this change.",
|
||||
"save": "Save",
|
||||
"dialog": {
|
||||
"add": "Add a user",
|
||||
"role": "Change {{name}}'s role",
|
||||
"disable": "Disable {{name}}?",
|
||||
"disableBody": "{{name}} is signed out everywhere and cannot sign in until enabled again. Their workspaces and history stay.",
|
||||
"enable": "Enable {{name}}?",
|
||||
"password": "Reset {{name}}'s password",
|
||||
"passwordBody": "{{name}} is signed out everywhere and signs in with the new password.",
|
||||
"workspace": "Give {{name}} a workspace",
|
||||
"workspaceBody": "The workspace's sessions and costs move to {{name}}. A member sees only their own workspaces."
|
||||
},
|
||||
"done": {
|
||||
"created": "{{name}} added",
|
||||
"role": "{{name}} is now {{role}}",
|
||||
"disabled": "{{name}} disabled",
|
||||
"enabled": "{{name}} enabled",
|
||||
"password": "{{name}}'s password reset",
|
||||
"workspace": "Workspace given to {{name}}"
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
"EEXISTS": "That username is taken.",
|
||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||
"EBADPASSWORD": "Password: at least 8 characters."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a member owns.",
|
||||
"auditEmpty": "Nothing recorded yet."
|
||||
}
|
||||
@@ -58,11 +58,7 @@
|
||||
"changes.collapseContext": "Bớt dòng xung quanh",
|
||||
"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.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các file đã tick)",
|
||||
"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",
|
||||
@@ -79,6 +75,7 @@
|
||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||
"changes.commentSend": "Dán vào pane",
|
||||
"changes.addToChat": "Thêm vào chat",
|
||||
"changes.commentCancel": "Huỷ",
|
||||
"changes.commentNoPane": "Chưa có terminal nào của workspace này đang hiện — hãy mở session (hoặc thu hẹp khung Changes) trước",
|
||||
"changes.layout.split": "Hai cột",
|
||||
@@ -105,13 +102,7 @@
|
||||
"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?",
|
||||
"changes.stagedGroup": "Đã stage",
|
||||
"changes.unstagedGroup": "Thay đổi",
|
||||
"changes.stage": "Đưa file này vào index",
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
@@ -236,10 +227,8 @@
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"changes.stageHunk": "Stage đoạn này",
|
||||
"changes.undoHunk": "Hoàn tác đoạn này",
|
||||
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục",
|
||||
"changes.stageHunkShort": "Giữ",
|
||||
"changes.undoHunkShort": "Hoàn tác",
|
||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||
"changes.edit": "Sửa",
|
||||
@@ -285,7 +274,7 @@
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
||||
"changes.discardShortConfirm": "Xoá?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendHint": "Gộp phần đã stage vào commit cuối (chỉ khi chưa push). Để trống message để giữ message cũ.",
|
||||
"changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.",
|
||||
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
|
||||
"changes.historyOf": "Lịch sử của nhánh",
|
||||
"changes.cherryPick": "Cherry-pick vào {{branch}}",
|
||||
@@ -435,6 +424,7 @@
|
||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||
"panes.closeShort": "Tách ra tab riêng",
|
||||
"panes.stop": "Dừng phiên",
|
||||
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
||||
"changes.viewCommit": "Xem commit",
|
||||
"changes.copySha": "Sao chép SHA của commit",
|
||||
@@ -475,5 +465,30 @@
|
||||
"runtime.listening": "đang lắng nghe",
|
||||
"runtime.notListening": "không lắng nghe",
|
||||
"replay.pasted": "Session đó đã mở trong một tab — tin nhắn đã được dán vào đó; nhấn Enter để gửi.",
|
||||
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}"
|
||||
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}",
|
||||
"changes.changesGroup": "Thay đổi",
|
||||
"changes.unversionedGroup": "File chưa theo dõi",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit và Push",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
"changes.tickFolder": "Tick mọi file trong {{path}}",
|
||||
"changes.commitFile": "Commit file…",
|
||||
"changes.jumpToSource": "Mở mã nguồn",
|
||||
"changes.copyPatch": "Sao chép dạng patch",
|
||||
"changes.noPatch": "File này không có diff dạng văn bản để sao chép",
|
||||
"changes.addToVcs": "Thêm vào VCS",
|
||||
"changes.addToGitignore": "Thêm vào .gitignore",
|
||||
"changes.includeBlock": "Đưa thay đổi này vào commit",
|
||||
"changes.prevDiff": "Khác biệt trước (Shift+F7)",
|
||||
"changes.nextDiff": "Khác biệt tiếp theo (F7)",
|
||||
"changes.prevFile": "File trước",
|
||||
"changes.nextFile": "File tiếp theo",
|
||||
"changes.showHistory": "Xem lịch sử",
|
||||
"changes.historyOfFile": "Lịch sử của {{path}}",
|
||||
"changes.historyWholeBranch": "Cả nhánh",
|
||||
"changes.annotate": "Annotate",
|
||||
"changes.noBlame": "Không có gì để annotate: file này chưa có lịch sử commit",
|
||||
"changes.notCommitted": "Chưa commit"
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"quotaFailed": "Không đọc được quota",
|
||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
|
||||
"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",
|
||||
@@ -94,5 +95,6 @@
|
||||
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
|
||||
"closeMenu": "Đóng menu",
|
||||
"sidebar": "Thanh bên",
|
||||
"now": "bây giờ"
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng"
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@
|
||||
"incomplete": "Hooks chưa đầy đủ",
|
||||
"reinstall": "Cài lại hooks",
|
||||
"success": "Cài hooks thành công",
|
||||
"failed": "Cài hooks thất bại"
|
||||
"failed": "Cài hooks thất bại",
|
||||
"plugin": "Do plugin agenthub cung cấp (trong plugin.json của nó), không nằm trong settings.json"
|
||||
},
|
||||
"lanesSection": {
|
||||
"title": "Workspace",
|
||||
@@ -311,16 +312,51 @@
|
||||
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
|
||||
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
|
||||
},
|
||||
"tray": {
|
||||
"label": "Hiện icon AgentHub trên thanh trạng thái",
|
||||
"desc": "Icon AgentHub cạnh đồng hồ (thanh trên cùng, menu bar hoặc khay hệ thống) để mở dashboard, thoát, và bật lại sau khi thoát.",
|
||||
"unavailable": "Không khả dụng ở đây: cần phiên desktop có giao diện đồ hoạ, và luôn tắt ở server mode."
|
||||
},
|
||||
"quit": {
|
||||
"label": "Thoát AgentHub",
|
||||
"desc": "Tắt dashboard để không còn chạy ngầm. Mở Claude Code sẽ không tự bật lại; chạy `agenthub start` để bật lại.",
|
||||
"button": "Thoát",
|
||||
"confirmTitle": "Thoát AgentHub?",
|
||||
"confirmMessage": "Mọi terminal của các workspace sẽ bị đóng — agent đang chạy sẽ dừng — và dashboard tắt. Nó giữ tắt cho tới khi bạn chạy `agenthub start`.",
|
||||
"confirmMessageAutostart": "Mọi terminal của các workspace sẽ bị đóng — agent đang chạy sẽ dừng — và dashboard tắt. Nó giữ tắt cho tới khi bạn chạy `agenthub start`, hoặc tới lần khởi động/đăng nhập máy kế tiếp (đang bật tự khởi động).",
|
||||
"done": "AgentHub đang tắt. Chạy `agenthub start` để bật lại."
|
||||
},
|
||||
"about": {
|
||||
"title": "Giới thiệu",
|
||||
"description": "Thông tin runtime của máy chủ.",
|
||||
"title": "Hệ thống",
|
||||
"release": "Phiên bản",
|
||||
"uiBuild": "UI build v{{version}} (hãy build lại client để khớp)",
|
||||
"uptime": "Thời gian chạy",
|
||||
"nodejs": "Node.js",
|
||||
"platform": "Nền tảng",
|
||||
"wsClients": "WS Clients",
|
||||
"loadingInfo": "Đang tải thông tin máy chủ..."
|
||||
"wsClients": "Kết nối trực tiếp",
|
||||
"wsClientsDesc": "Các tab trình duyệt và công cụ đang nhận cập nhật trực tiếp",
|
||||
"loadingInfo": "Đang tải thông tin máy chủ...",
|
||||
"server": "Máy chủ",
|
||||
"cpu": "CPU",
|
||||
"cpuValue": "{{count}} lõi · tải {{load}}",
|
||||
"memory": "Bộ nhớ tiến trình",
|
||||
"memoryValue": "{{rss}} RSS · heap {{used}} / {{limit}}",
|
||||
"systemMemory": "Bộ nhớ hệ thống",
|
||||
"systemMemoryValue": "Đã dùng {{used}} / {{total}}",
|
||||
"install": "Cài đặt",
|
||||
"installKind": {
|
||||
"plugin": "Plugin Claude Code",
|
||||
"checkout": "Git checkout"
|
||||
},
|
||||
"latest": "Bản phát hành mới nhất",
|
||||
"upToDate": "Đã mới nhất",
|
||||
"updateAvailable": "Có bản cập nhật",
|
||||
"unknown": "Không rõ",
|
||||
"database": "Cơ sở dữ liệu",
|
||||
"dbFile": "Tệp cơ sở dữ liệu",
|
||||
"journal": "Chế độ journal",
|
||||
"activity": "Sự kiện đã ghi",
|
||||
"activityValue": "{{m5}} trong 5 phút · {{m15}} trong 15 phút · {{h1}} trong 1 giờ"
|
||||
},
|
||||
"tables": {
|
||||
"sessions": "phiên",
|
||||
@@ -764,9 +800,7 @@
|
||||
"group": {
|
||||
"general": "Chung",
|
||||
"agents": "Tác tử",
|
||||
"routing": "Định tuyến",
|
||||
"git": "Git",
|
||||
"cost": "Chi phí"
|
||||
"data": "Dữ liệu"
|
||||
},
|
||||
"section": {
|
||||
"appearance": {
|
||||
@@ -800,37 +834,25 @@
|
||||
"title": "Định giá",
|
||||
"description": "Giá mô hình của mọi nhà cung cấp, dùng để ước tính chi phí. Đơn giá theo một triệu token (USD); % là ký tự đại diện trong mẫu; giá giới thiệu hiển thị màu tím."
|
||||
},
|
||||
"gitCredentials": {
|
||||
"title": "Git credentials",
|
||||
"description": "Thông tin đăng nhập AgentHub đưa cho git khi fetch, pull hoặc push từ dashboard."
|
||||
},
|
||||
"worktrees": {
|
||||
"title": "Worktree",
|
||||
"description": "Những git worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace."
|
||||
},
|
||||
"data": {
|
||||
"title": "Dữ liệu",
|
||||
"description": "Tổng quan chi phí và cơ sở dữ liệu, nhập, nguồn từ xa, xuất, dọn dẹp và vùng nguy hiểm."
|
||||
},
|
||||
"about": {
|
||||
"title": "Giới thiệu",
|
||||
"description": "Phiên bản máy chủ, thời gian hoạt động và môi trường chạy."
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Snapshot",
|
||||
"description": "Các bản sao chỉ đọc, đã đóng băng của phiên, mỗi bản có liên kết riêng."
|
||||
"title": "Cơ sở dữ liệu",
|
||||
"description": "Tổng chi phí, tổng quan cơ sở dữ liệu, nhập, nguồn từ xa, xuất, dọn dẹp và vùng nguy hiểm."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Báo cáo",
|
||||
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
|
||||
"title": "Snapshot & báo cáo",
|
||||
"description": "Bản snapshot phiên chỉ đọc, mỗi bản một liên kết riêng, và bản tóm tắt phân tích ghi ra theo lịch."
|
||||
},
|
||||
"users": {
|
||||
"title": "Người dùng",
|
||||
"description": "Tài khoản, mức dùng và nhật ký truy cập."
|
||||
"system": {
|
||||
"title": "Hệ thống",
|
||||
"description": "Phiên bản, cách cài đặt, tự khởi động, runtime máy chủ và tệp cơ sở dữ liệu."
|
||||
},
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"description": "Thông tin đăng nhập AgentHub đưa cho git khi fetch, pull, push từ dashboard, và các worktree được gợi ý khi Thêm thư mục."
|
||||
}
|
||||
},
|
||||
"backPrevious": "Quay lại",
|
||||
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Chung."
|
||||
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Giao diện."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Tìm cài đặt",
|
||||
@@ -856,6 +878,18 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Chỉ lưu trong trình duyệt này.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
"maple": "Maple Mono (bo tròn)",
|
||||
"system": "Font hệ thống"
|
||||
},
|
||||
"systemNote": "Font độ rộng cố định của trình duyệt. Font này có thể thiếu một số chữ tiếng Việt (ấ, ế, ố…), khi đó chữ sẽ bị lệch ô.",
|
||||
"preview": "Xem trước"
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Sao chép host",
|
||||
"copyUrl": "Sao chép endpoint",
|
||||
@@ -947,20 +981,9 @@
|
||||
"imported": "Đã nhập {{count}} tùy chọn. Đang tải lại…",
|
||||
"badFile": "Tệp này không phải tệp tùy chọn của AgentHub."
|
||||
},
|
||||
"users": {
|
||||
"title": "Người dùng",
|
||||
"description": "Mọi tài khoản trên server này, cùng workspace, session và mức dùng gần đây.",
|
||||
"name": "Người dùng",
|
||||
"role": "Vai trò",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Session",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"disabled": "đã khoá",
|
||||
"tokens": "{{n}} token",
|
||||
"manageHint": "Thêm, khoá hoặc sửa người dùng trên máy chủ: agenthub users …",
|
||||
"auditTitle": "Nhật ký truy cập",
|
||||
"auditDescription": "Mỗi lần admin hoặc analyst mở một session của member.",
|
||||
"auditEmpty": "Chưa có lượt đọc nào."
|
||||
"pricingLink": {
|
||||
"title": "Giá model",
|
||||
"description": "Giá model của mọi nhà cung cấp nằm chung một trang.",
|
||||
"open": "Mở trang Giá"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"title": "Người dùng",
|
||||
"description": "Mọi tài khoản trên server này, kèm workspace, phiên và mức dùng gần đây.",
|
||||
"name": "Người dùng",
|
||||
"role": "Vai trò",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Phiên",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"disabled": "đã khoá",
|
||||
"you": "bạn",
|
||||
"tokens": "{{n}} token",
|
||||
"actions": "Thao tác",
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"analyst": "Analyst",
|
||||
"member": "Thành viên"
|
||||
},
|
||||
"roleHint": {
|
||||
"admin": "Toàn quyền, kể cả cấu hình của server và dữ liệu của mọi người",
|
||||
"analyst": "Xem được mọi thứ; không có terminal, không thay đổi gì",
|
||||
"member": "Chỉ workspace của chính mình"
|
||||
},
|
||||
"add": "Thêm người dùng",
|
||||
"username": "Tên đăng nhập",
|
||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Ít nhất 8 ký tự",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"changeRole": "Đổi vai trò",
|
||||
"disable": "Khoá",
|
||||
"enable": "Mở khoá",
|
||||
"resetPassword": "Đặt lại mật khẩu",
|
||||
"assignWorkspace": "Giao workspace",
|
||||
"workspace": "Workspace",
|
||||
"noOwner": "chưa có chủ",
|
||||
"ownedBy": "của {{name}}",
|
||||
"noWorkspaces": "Chưa có workspace nào.",
|
||||
"yourPassword": "Mật khẩu của bạn",
|
||||
"yourPasswordHint": "Nhập mật khẩu của chính bạn để xác nhận thay đổi này.",
|
||||
"save": "Lưu",
|
||||
"dialog": {
|
||||
"add": "Thêm người dùng",
|
||||
"role": "Đổi vai trò của {{name}}",
|
||||
"disable": "Khoá {{name}}?",
|
||||
"disableBody": "{{name}} bị đăng xuất khỏi mọi nơi và không đăng nhập được cho tới khi mở khoá. Workspace và lịch sử vẫn giữ nguyên.",
|
||||
"enable": "Mở khoá {{name}}?",
|
||||
"password": "Đặt lại mật khẩu cho {{name}}",
|
||||
"passwordBody": "{{name}} bị đăng xuất khỏi mọi nơi và đăng nhập lại bằng mật khẩu mới.",
|
||||
"workspace": "Giao workspace cho {{name}}",
|
||||
"workspaceBody": "Các phiên và chi phí của workspace chuyển sang {{name}}. Thành viên chỉ thấy workspace của chính mình."
|
||||
},
|
||||
"done": {
|
||||
"created": "Đã thêm {{name}}",
|
||||
"role": "{{name}} giờ là {{role}}",
|
||||
"disabled": "Đã khoá {{name}}",
|
||||
"enabled": "Đã mở khoá {{name}}",
|
||||
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
||||
"workspace": "Đã giao workspace cho {{name}}"
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||
"EBADPASSWORD": "Mật khẩu: ít nhất 8 ký tự."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một thành viên.",
|
||||
"auditEmpty": "Chưa ghi nhận gì."
|
||||
}
|
||||
@@ -82,7 +82,7 @@
|
||||
--syn-del: 192 31 42; /* #c01f2a */
|
||||
/* 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). */
|
||||
/* An unversioned (untracked) file in the Changes panel, JetBrains' orange. */
|
||||
--kept: 194 65 12; /* orange-700 #c2410c */
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,8 @@ describe("rankSettingsEntries", () => {
|
||||
expect(tos).toContain(`agents/claude-code?tab=${t}`);
|
||||
expect(tos).toContain("data#cleanup");
|
||||
expect(tos).toContain("data#prefs");
|
||||
expect(SETTINGS_INDEX.find((e) => e.section === "worktrees")?.keywords).not.toContain(
|
||||
"sidebar"
|
||||
);
|
||||
expect(
|
||||
SETTINGS_INDEX.find((e) => e.titleKey === "worktrees.showUntracked")?.keywords
|
||||
).not.toContain("sidebar");
|
||||
});
|
||||
});
|
||||
|
||||
+77
-11
@@ -397,6 +397,7 @@ import type {
|
||||
Lane,
|
||||
LaneChangeRange,
|
||||
LaneChanges,
|
||||
LaneBlame,
|
||||
LaneCommit,
|
||||
LaneFileContent,
|
||||
LaneTreeEntry,
|
||||
@@ -673,8 +674,42 @@ export const api = {
|
||||
users: {
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
||||
/** GET /api/users/audit — staff reads of members' sessions, newest first. */
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
// Account changes (admin only). Each carries the admin's OWN password
|
||||
// again (`currentPassword`): the server refuses without it (EREAUTH).
|
||||
/** POST /api/users — a new account. */
|
||||
create: (
|
||||
body: { username: string; password: string; role: AuthUser["role"] },
|
||||
currentPassword: string
|
||||
) =>
|
||||
request<{ user: AuthUser & { disabled: boolean } }>("/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** PATCH /api/users/:id — role and/or disabled (never your own). */
|
||||
update: (
|
||||
id: number,
|
||||
body: { role?: AuthUser["role"]; disabled?: boolean },
|
||||
currentPassword: string
|
||||
) =>
|
||||
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** POST /api/users/:id/password — a new password; that user's logins end. */
|
||||
setPassword: (id: number, password: string, currentPassword: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, currentPassword }),
|
||||
}),
|
||||
/** POST /api/users/:id/workspaces — give that user a workspace. */
|
||||
assignWorkspace: (id: number, laneId: number, currentPassword: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/workspaces`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ laneId, currentPassword }),
|
||||
}),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
/** Self-update status: whether this install is a git clone and, if so,
|
||||
@@ -1113,7 +1148,13 @@ export const api = {
|
||||
};
|
||||
load_stats: { m5: number; m15: number; h1: number };
|
||||
};
|
||||
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
hooks: {
|
||||
installed: boolean;
|
||||
path: string;
|
||||
hooks: Record<string, boolean>;
|
||||
/** "plugin" when the enabled agenthub plugin provides the hooks. */
|
||||
source?: "plugin";
|
||||
};
|
||||
server: {
|
||||
version: string;
|
||||
uptime: number;
|
||||
@@ -1197,6 +1238,21 @@ export const api = {
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
},
|
||||
/** AgentHub's icon in the OS status area — on by default; a change starts
|
||||
* or stops the icon process, never the server. */
|
||||
tray: {
|
||||
/** GET /api/settings/tray */
|
||||
get: () => request<TrayStatus>("/settings/tray"),
|
||||
/** PUT /api/settings/tray */
|
||||
set: (enabled: boolean) =>
|
||||
request<TrayStatus>("/settings/tray", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
},
|
||||
/** POST /api/settings/quit — close every workspace terminal and stop the
|
||||
* server until `agenthub start` (or a boot/login with autostart on). */
|
||||
quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }),
|
||||
/** Opt-in secret redaction of stored payloads and the folders whose hooks
|
||||
* are dropped (`server/lib/privacy.js`). */
|
||||
privacy: {
|
||||
@@ -2891,6 +2947,9 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @param opts `file` narrows the list to the commits touching one path.
|
||||
*/
|
||||
/** GET /api/lanes/:id/git/blame — Annotate: each line's last commit. */
|
||||
gitBlame: (id: GitTarget, path: string) =>
|
||||
request<LaneBlame>(gitUrl(id, "/blame", new URLSearchParams({ path }))),
|
||||
gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.limit) q.set("limit", String(opts.limit));
|
||||
@@ -2978,8 +3037,11 @@ export const api = {
|
||||
* 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" }),
|
||||
suggestCommitMessage: (id: GitTarget, paths?: string[]) =>
|
||||
request<{ message: string }>(gitUrl(id, "/commit-message"), {
|
||||
method: "POST",
|
||||
...(paths ? { body: JSON.stringify({ paths }) } : {}),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
@@ -3016,7 +3078,8 @@ export const api = {
|
||||
| "cherry-pick"
|
||||
| "revert"
|
||||
| "undo-commit"
|
||||
| "amend",
|
||||
| "amend"
|
||||
| "ignore",
|
||||
body: {
|
||||
/** cherry-pick / revert: the commit. */
|
||||
sha?: string;
|
||||
@@ -3278,12 +3341,7 @@ function requestCodexBackupsHelper(params?: {
|
||||
/** Kind of Claude Code config artifact manageable via `api.ccConfig.write`/
|
||||
* `delete` - each maps to a distinct on-disk location under `.claude/`. */
|
||||
export type CcArtifactType =
|
||||
| "skills"
|
||||
| "agents"
|
||||
| "commands"
|
||||
| "output-styles"
|
||||
| "memory"
|
||||
| "auto-memory";
|
||||
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
|
||||
|
||||
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
|
||||
export interface CcWriteArgs {
|
||||
@@ -4436,6 +4494,14 @@ export interface AutostartStatus {
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/settings/tray. `available` false: server mode, or no graphical
|
||||
* session on this Linux machine. */
|
||||
export interface TrayStatus {
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/settings/privacy. A field the environment set is `env_owned`
|
||||
* and cannot be saved from the dashboard. */
|
||||
export interface PrivacySettings {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file panePaste.ts — lets another part of the Workspace page paste text into
|
||||
* a workspace's terminal (a diff-line comment from the Changes panel). Every
|
||||
* mounted TerminalView registers its run id; the text goes to the pane of that
|
||||
* workspace the user focused last, else any of its panes. It is a paste —
|
||||
* workspace focused last — clicked into, or made the Workspace's focused pane
|
||||
* (a tab, Alt+1–4, the sidebar) — else any of its panes. It is a paste —
|
||||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||||
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
|
||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||
|
||||
@@ -39,6 +39,13 @@ export const SETTINGS_INDEX: {
|
||||
to: "#language",
|
||||
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "terminalFont.title",
|
||||
descKey: "terminalFont.description",
|
||||
keywords: ["font", "phông", "maple", "jetbrains", "monospace", "rounded", "bo tròn"],
|
||||
to: "#terminal-font",
|
||||
},
|
||||
{
|
||||
section: "data",
|
||||
titleKey: "pushIngest.title",
|
||||
@@ -100,16 +107,28 @@ export const SETTINGS_INDEX: {
|
||||
to: "#danger",
|
||||
},
|
||||
{
|
||||
section: "about",
|
||||
section: "system",
|
||||
titleKey: "about.title",
|
||||
keywords: ["version", "server", "database", "uptime"],
|
||||
keywords: ["about", "version", "server", "database", "uptime", "memory", "cpu", "install"],
|
||||
},
|
||||
{
|
||||
section: "about",
|
||||
section: "system",
|
||||
titleKey: "autostart.label",
|
||||
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "quit.label",
|
||||
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "tray.label",
|
||||
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
|
||||
to: "#autostart",
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "sagi.title",
|
||||
@@ -126,10 +145,11 @@ export const SETTINGS_INDEX: {
|
||||
to: "#pet",
|
||||
},
|
||||
{
|
||||
section: "snapshots",
|
||||
section: "reports",
|
||||
titleKey: "snapshots.title",
|
||||
descKey: "snapshots.description",
|
||||
keywords: ["share", "freeze", "link", "redact"],
|
||||
to: "#snapshots",
|
||||
},
|
||||
{
|
||||
section: "reports",
|
||||
@@ -178,7 +198,6 @@ export const SETTINGS_INDEX: {
|
||||
descKey: "aiTools.description",
|
||||
keywords: ["install", "update", "default"],
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "pricing.title", keywords: ["cost", "price"] },
|
||||
{ section: "agents/claude-code", titleKey: "hooks.title" },
|
||||
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
|
||||
@@ -237,7 +256,6 @@ export const SETTINGS_INDEX: {
|
||||
keywords: ["account", "login", "quota", "switch"],
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "pricing.title", keywords: ["cost", "price"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
{ section: "agents/codex", titleKey: "codexHome.title" },
|
||||
{ section: "agents/codex", titleKey: "codexConfig:tabs.skills", to: "?tab=skills" },
|
||||
@@ -261,10 +279,15 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
||||
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
|
||||
{ section: "worktrees", titleKey: "worktrees.claude" },
|
||||
{ section: "worktrees", titleKey: "worktrees.agenthub" },
|
||||
{ section: "git", titleKey: "gitCreds.title", keywords: ["login", "token"] },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "worktrees.showUntracked",
|
||||
keywords: ["hide", "suggestions"],
|
||||
to: "#worktrees",
|
||||
},
|
||||
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
|
||||
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
|
||||
];
|
||||
|
||||
function tier(hay: string | undefined, q: string, exact: number): number {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @file terminalFont — the lane terminal's font, a viewer preference kept in
|
||||
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
|
||||
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled
|
||||
* webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
|
||||
* and those glyphs then fall back to a face of another width).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const TERMINAL_FONT_KEY = "agenthub.terminal.font";
|
||||
|
||||
export const TERMINAL_FONTS = {
|
||||
jetbrains: '"JetBrains Mono", monospace',
|
||||
maple: '"Maple Mono", monospace',
|
||||
system: "monospace",
|
||||
} as const;
|
||||
|
||||
export type TerminalFont = keyof typeof TERMINAL_FONTS;
|
||||
|
||||
export function readTerminalFont(): TerminalFont {
|
||||
try {
|
||||
const v = window.localStorage.getItem(TERMINAL_FONT_KEY);
|
||||
if (v && v in TERMINAL_FONTS) return v as TerminalFont;
|
||||
} catch {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
return "jetbrains";
|
||||
}
|
||||
|
||||
export function writeTerminalFont(font: TerminalFont) {
|
||||
try {
|
||||
window.localStorage.setItem(TERMINAL_FONT_KEY, font);
|
||||
} catch {
|
||||
/* storage blocked — terminals keep the default */
|
||||
}
|
||||
}
|
||||
+24
-3
@@ -1211,9 +1211,15 @@ export interface CostResult {
|
||||
|
||||
/** 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 }>;
|
||||
id: "claude" | "codex" | "antigravity" | "cursor" | "grok" | "kimi" | "opencode";
|
||||
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
|
||||
* `group` names the model pool a window belongs to (Antigravity's). */
|
||||
windows: Array<{
|
||||
label: string;
|
||||
usedPercent: number | null;
|
||||
resetsAt: string | null;
|
||||
group?: string;
|
||||
}>;
|
||||
/** 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. */
|
||||
@@ -1222,6 +1228,8 @@ export interface ToolQuota {
|
||||
label?: string | null;
|
||||
/** The signed-in account's email, when the tool's login names one. */
|
||||
account?: string | null;
|
||||
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
|
||||
plan?: 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). */
|
||||
@@ -2648,6 +2656,8 @@ export interface Lane {
|
||||
id: number;
|
||||
title: string;
|
||||
cwd: string;
|
||||
/** Server mode: the account that owns this workspace (null = nobody yet). */
|
||||
owner_id?: number | null;
|
||||
branch: string | null;
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
@@ -2927,6 +2937,17 @@ export interface LaneDiffLine {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
|
||||
* the working-tree file (`null` = not committed yet). */
|
||||
export type LaneBlame =
|
||||
| {
|
||||
available: true;
|
||||
path: string;
|
||||
commits: Record<string, { short: string; author: string; date: string; summary: string }>;
|
||||
lines: (string | null)[];
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
/** One `@@` block of a file's diff. */
|
||||
export interface LaneDiffHunk {
|
||||
header: string;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ## Fonts
|
||||
* Only Latin subsets of Inter and JetBrains Mono are imported so Vite bundles
|
||||
* lean WOFF2 files instead of every glyph subset Google Fonts would serve.
|
||||
* Maple Mono (the terminal's optional rounded font) ships as one file per weight.
|
||||
*
|
||||
* ## Service worker reload policy
|
||||
* On first visit there is no controlling SW yet — we must not reload when the
|
||||
@@ -62,6 +63,10 @@ import "@fontsource/jetbrains-mono/latin-500.css";
|
||||
// visually mangled inside xterm's fixed-width cells.
|
||||
import "@fontsource/jetbrains-mono/vietnamese-400.css";
|
||||
import "@fontsource/jetbrains-mono/vietnamese-500.css";
|
||||
// The rounded terminal font (Settings → Appearance). Its one file already holds
|
||||
// the Vietnamese glyphs; a face is only downloaded once something uses it.
|
||||
import "@fontsource/maple-mono/400.css";
|
||||
import "@fontsource/maple-mono/700.css";
|
||||
import App from "./App";
|
||||
import { AuthGate } from "./components/AuthGate";
|
||||
import { i18nReady } from "./i18n";
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { Lane } from "../lib/types";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { ModelPicker } from "../components/run/RunSetup";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { PricingLink } from "../components/settings/PricingSettings";
|
||||
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
|
||||
|
||||
/** Cards one tool adds below the generic ones. */
|
||||
@@ -307,7 +307,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
|
||||
</Card>
|
||||
)}
|
||||
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
|
||||
{tool.modelVendor && <PricingLink vendor={tool.modelVendor} />}
|
||||
{tool.configFiles > 0 && (
|
||||
<Card title={t("toolTab.config")} Icon={FileCog}>
|
||||
<ConfigFiles toolId={toolId} />
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* union is exactly "user" | "project". Its **Overview** also holds the
|
||||
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
|
||||
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
|
||||
* Pricing for OpenAI models (the shared `PricingSettings` table).
|
||||
* A link to the OpenAI table on the Pricing page (`PricingLink`).
|
||||
* Every card with action buttons also opens a right-click menu repeating
|
||||
* them (same handlers — Delete still asks for confirmation) plus Copy
|
||||
* name/path.
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { PricingLink } from "../components/settings/PricingSettings";
|
||||
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
|
||||
import {
|
||||
Empty,
|
||||
@@ -530,7 +530,7 @@ export function CodexConfig() {
|
||||
}
|
||||
/>
|
||||
<CodexHooksSettings key={hooksKey} />
|
||||
<PricingSettings vendor="openai" id="codex-pricing" />
|
||||
<PricingLink vendor="openai" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+158
-90
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @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, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), 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"` — the start-at-login switch (`AutostartSettings`) and 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>`.
|
||||
* @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, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), 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"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -55,7 +55,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "../components/Checkbox";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useState, useCallback, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DollarSign,
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
HardDrive,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Bell,
|
||||
BellOff,
|
||||
BellRing,
|
||||
@@ -76,10 +75,6 @@ import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
Clock,
|
||||
Cpu,
|
||||
Globe,
|
||||
Wifi,
|
||||
Activity,
|
||||
Users,
|
||||
Layers,
|
||||
@@ -116,7 +111,7 @@ import {
|
||||
SettingsSegmented,
|
||||
SettingsSwitchRow,
|
||||
} from "../components/settings/SettingsRow";
|
||||
import type { WSMessage } from "../lib/types";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
@@ -145,22 +140,13 @@ const NOTIF_TYPES: [NotifType, string][] = [
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
interface SystemInfo {
|
||||
db: { path: string; size: number; counts: Record<string, number> };
|
||||
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
server: {
|
||||
version: string;
|
||||
uptime: number;
|
||||
node_version: string;
|
||||
platform: string;
|
||||
ws_connections: number;
|
||||
};
|
||||
}
|
||||
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
@@ -1133,85 +1119,167 @@ export function Settings({
|
||||
)}
|
||||
|
||||
{section === "about" && (
|
||||
<section id="about" className="scroll-mt-24">
|
||||
<div className="mb-4">
|
||||
<AutostartSettings />
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
|
||||
<div id="about" className="space-y-6 scroll-mt-24">
|
||||
<AutostartSettings />
|
||||
{sysInfo ? (
|
||||
<div className="card p-5">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.release")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary font-mono">
|
||||
v{sysInfo.server.version}
|
||||
</p>
|
||||
{sysInfo.server.version !== __APP_VERSION__ && (
|
||||
<p className="text-[10px] text-status-warning/90 mt-1">
|
||||
{t("about.uiBuild", { version: __APP_VERSION__ })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Clock className="w-4 h-4 text-accent" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary">
|
||||
{formatUptime(sysInfo.server.uptime)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Cpu className="w-4 h-4 text-status-success" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.nodejs")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary font-mono">
|
||||
{sysInfo.server.node_version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Globe className="w-4 h-4 text-syntax-keyword" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.platform")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary">
|
||||
{sysInfo.server.platform}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Wifi className="w-4 h-4 text-status-warning" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.wsClients")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary">
|
||||
{sysInfo.server.ws_connections}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SystemDetails info={sysInfo} />
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("about.loadingInfo")}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A read-only value on the right of a System row. */
|
||||
function Value({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<span className={`text-sm text-fg-primary ${mono ? "font-mono text-xs" : ""}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The System page's facts: the server process, how AgentHub is installed (from
|
||||
* the update check's last answer — it never fetches), and the database file.
|
||||
*/
|
||||
function SystemDetails({ info }: { info: SystemInfo }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [upd, setUpd] = useState<UpdateStatusPayload | null>(null);
|
||||
useEffect(() => {
|
||||
api.updates
|
||||
.status()
|
||||
.then(setUpd)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const srv = info.server;
|
||||
const mem = srv.memory;
|
||||
const heapLimit = mem ? (mem.heapLimit ?? mem.heapTotal) : 0;
|
||||
const load = srv.cpu_load?.map((n) => n.toFixed(2)).join(" / ");
|
||||
const kind = upd?.install_kind;
|
||||
return (
|
||||
<>
|
||||
<SettingsSection id="server" title={t("about.server")}>
|
||||
<SettingsRow
|
||||
label={t("about.release")}
|
||||
description={
|
||||
srv.version !== __APP_VERSION__ ? (
|
||||
<span className="text-status-warning/90">
|
||||
{t("about.uiBuild", { version: __APP_VERSION__ })}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
control={<Value mono>v{srv.version}</Value>}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("about.uptime")}
|
||||
control={<Value>{formatUptime(srv.uptime)}</Value>}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("about.platform")}
|
||||
control={
|
||||
<Value mono>
|
||||
{srv.platform}
|
||||
{srv.arch ? ` · ${srv.arch}` : ""} · Node.js {srv.node_version}
|
||||
</Value>
|
||||
}
|
||||
/>
|
||||
{srv.cpus > 0 && (
|
||||
<SettingsRow
|
||||
label={t("about.cpu")}
|
||||
control={<Value>{t("about.cpuValue", { count: srv.cpus, load })}</Value>}
|
||||
/>
|
||||
)}
|
||||
{mem && (
|
||||
<SettingsRow
|
||||
label={t("about.memory")}
|
||||
control={
|
||||
<Value>
|
||||
{t("about.memoryValue", {
|
||||
rss: formatBytes(mem.rss),
|
||||
used: formatBytes(mem.heapUsed),
|
||||
limit: formatBytes(heapLimit),
|
||||
})}
|
||||
</Value>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{srv.total_mem > 0 && (
|
||||
<SettingsRow
|
||||
label={t("about.systemMemory")}
|
||||
control={
|
||||
<Value>
|
||||
{t("about.systemMemoryValue", {
|
||||
used: formatBytes(srv.total_mem - srv.free_mem),
|
||||
total: formatBytes(srv.total_mem),
|
||||
})}
|
||||
</Value>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingsRow
|
||||
label={t("about.wsClients")}
|
||||
description={t("about.wsClientsDesc")}
|
||||
control={<Value>{srv.ws_connections}</Value>}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{upd && (
|
||||
<SettingsSection id="install" title={t("about.install")}>
|
||||
<SettingsRow
|
||||
label={kind ? t(`about.installKind.${kind}`) : t("about.unknown")}
|
||||
description={
|
||||
upd.repo_root ? (
|
||||
<span className="font-mono break-all">{upd.repo_root}</span>
|
||||
) : undefined
|
||||
}
|
||||
control={upd.local_version ? <Value mono>v{upd.local_version}</Value> : null}
|
||||
/>
|
||||
{upd.latest_release && (
|
||||
<SettingsRow
|
||||
label={t("about.latest")}
|
||||
control={
|
||||
<span className="flex items-center gap-2">
|
||||
<Value mono>{upd.latest_release}</Value>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
upd.update_available
|
||||
? "bg-status-warning/10 text-status-warning"
|
||||
: "bg-status-success/10 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{upd.update_available ? t("about.updateAvailable") : t("about.upToDate")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection id="database" title={t("about.database")}>
|
||||
<SettingsRow
|
||||
label={t("about.dbFile")}
|
||||
description={<span className="font-mono break-all">{info.db.path}</span>}
|
||||
control={<Value>{formatBytes(info.db.size)}</Value>}
|
||||
/>
|
||||
{info.db.pragmas && (
|
||||
<SettingsRow
|
||||
label={t("about.journal")}
|
||||
control={<Value mono>{info.db.pragmas.journal_mode}</Value>}
|
||||
/>
|
||||
)}
|
||||
{info.db.load_stats && (
|
||||
<SettingsRow
|
||||
label={t("about.activity")}
|
||||
control={<Value>{t("about.activityValue", info.db.load_stats)}</Value>}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Clear all data": the one action here that empties the whole database, so
|
||||
* it asks for a typed phrase (not a second click) and offers an export first. */
|
||||
function ClearDataDialog({
|
||||
|
||||
+175
-178
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description The Settings page: `/settings/<section>`, a left nav grouped
|
||||
* General / Agents / Routing / Cost / Git (a `<select>` below `md`), a
|
||||
* General / Agents / Data (a `<select>` below `md`), a
|
||||
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
|
||||
* focuses it — and selects its text when it already has focus —, a result list
|
||||
* deep-links a row's sub-tab and anchor, Enter opens the first result, Escape
|
||||
@@ -10,7 +10,10 @@
|
||||
* 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 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
|
||||
* `general` and `pet` redirect there). Small pages share one: System is the old
|
||||
* About (`about` redirects), Git holds git logins and worktree suggestions
|
||||
* (`git-credentials`, `worktrees`), Snapshots & reports is `reports`
|
||||
* (`snapshots`). A page's content is capped at a readable width. 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.
|
||||
@@ -31,17 +34,14 @@ import {
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
Database,
|
||||
Info,
|
||||
Bot,
|
||||
Camera,
|
||||
CalendarClock,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Palette,
|
||||
Router,
|
||||
Search,
|
||||
UsersRound,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
@@ -66,9 +66,8 @@ import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { UsersSettings } from "../components/settings/UsersSettings";
|
||||
|
||||
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
|
||||
const GROUPS = ["general", "agents", "data"] as const;
|
||||
type Group = (typeof GROUPS)[number];
|
||||
|
||||
interface Section {
|
||||
@@ -86,7 +85,7 @@ const LEGACY_TOOL: Record<string, string> = {
|
||||
"claude-code": "agents/claude-code",
|
||||
codex: "agents/codex",
|
||||
"ai-router": "ai-proxy",
|
||||
"git-credentials": "git-credentials",
|
||||
"git-credentials": "git",
|
||||
};
|
||||
|
||||
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
|
||||
@@ -125,16 +124,15 @@ const MOVED: Record<string, string> = {
|
||||
pet: "appearance#pet",
|
||||
// The AI Router → AI Proxy rename.
|
||||
"ai-router": "ai-proxy",
|
||||
// Small pages merged into one.
|
||||
about: "system",
|
||||
snapshots: "reports#snapshots",
|
||||
"git-credentials": "git",
|
||||
worktrees: "git#worktrees",
|
||||
};
|
||||
|
||||
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
|
||||
const STAFF_SECTIONS = new Set([
|
||||
"data",
|
||||
"reports",
|
||||
"agents/claude-code",
|
||||
"agents/codex",
|
||||
"git-credentials",
|
||||
]);
|
||||
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
@@ -219,27 +217,12 @@ export function SettingsHub() {
|
||||
|
||||
const auth = useAuth();
|
||||
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
|
||||
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
const s = (key: string) => ({
|
||||
title: t(`settingsNav.section.${key}.title`),
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const allSections: Section[] = [
|
||||
{
|
||||
id: "data",
|
||||
group: "general",
|
||||
Icon: Database,
|
||||
...s("data"),
|
||||
render: () => <Settings section="data" />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
group: "general",
|
||||
Icon: Info,
|
||||
...s("about"),
|
||||
render: () => <Settings section="about" />,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
group: "general",
|
||||
@@ -247,31 +230,6 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "snapshots",
|
||||
group: "general",
|
||||
Icon: Camera,
|
||||
...s("snapshots"),
|
||||
render: () => <SnapshotSettings />,
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
group: "general",
|
||||
Icon: CalendarClock,
|
||||
...s("reports"),
|
||||
render: () => <ReportSettings />,
|
||||
},
|
||||
...(isStaff
|
||||
? [
|
||||
{
|
||||
id: "users",
|
||||
group: "general" as const,
|
||||
Icon: UsersRound,
|
||||
...s("users"),
|
||||
render: () => <UsersSettings />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
@@ -279,6 +237,13 @@ export function SettingsHub() {
|
||||
...s("notifications"),
|
||||
render: () => <Settings section="notifications" />,
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
group: "general",
|
||||
Icon: Server,
|
||||
...s("system"),
|
||||
render: () => <Settings section="about" />,
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
group: "agents",
|
||||
@@ -322,14 +287,21 @@ export function SettingsHub() {
|
||||
})),
|
||||
{
|
||||
id: "ai-proxy",
|
||||
group: "routing",
|
||||
group: "agents",
|
||||
Icon: Router,
|
||||
...s("aiProxy"),
|
||||
render: () => <AiProxyConfig />,
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
group: "data",
|
||||
Icon: Database,
|
||||
...s("data"),
|
||||
render: () => <Settings section="data" />,
|
||||
},
|
||||
{
|
||||
id: "pricing",
|
||||
group: "cost",
|
||||
group: "data",
|
||||
Icon: Coins,
|
||||
...s("pricing"),
|
||||
render: () => (
|
||||
@@ -354,18 +326,39 @@ export function SettingsHub() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "git-credentials",
|
||||
group: "git",
|
||||
Icon: KeyRound,
|
||||
...s("gitCredentials"),
|
||||
render: () => <GitCredentialsConfig />,
|
||||
id: "reports",
|
||||
group: "data",
|
||||
Icon: CalendarClock,
|
||||
...s("reports"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div id="snapshots" className="scroll-mt-24">
|
||||
<SnapshotSettings />
|
||||
</div>
|
||||
{/* Scheduled reports read every user's sessions: staff only. */}
|
||||
{!isMember && <ReportSettings />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "worktrees",
|
||||
group: "git",
|
||||
id: "git",
|
||||
group: "data",
|
||||
Icon: GitBranch,
|
||||
...s("worktrees"),
|
||||
render: () => <WorktreeVisibilitySettings />,
|
||||
...s("git"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
{/* Git logins are the host's own: staff only. */}
|
||||
{!isMember && (
|
||||
<section id="git-credentials" className="scroll-mt-24">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||
<GitCredentialsConfig />
|
||||
</section>
|
||||
)}
|
||||
<div id="worktrees" className="scroll-mt-24">
|
||||
<WorktreeVisibilitySettings />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
// Server mode: a member cannot open the host's own configuration or the
|
||||
@@ -429,128 +422,132 @@ export function SettingsHub() {
|
||||
navigate(backDelta(historyIdx(), entryIdx.current ?? 0));
|
||||
};
|
||||
|
||||
const searchBox = (
|
||||
<label className="relative block">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.placeholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-sm text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const searchResults = ranked && results.length > 0 && (
|
||||
<ul
|
||||
aria-label={t("search.results")}
|
||||
className="card mt-2 divide-y divide-border/60 overflow-hidden"
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
||||
<Link
|
||||
to={hrefOf(r)}
|
||||
onClick={() => setQuery("")}
|
||||
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
|
||||
>
|
||||
<span className="block text-fg-primary">{r.title}</span>
|
||||
{!r.sectionTitle && (
|
||||
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Link
|
||||
to="/workspace"
|
||||
onClick={back}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
|
||||
</Link>
|
||||
|
||||
<label className="relative block md:w-56">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.placeholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{ranked && (
|
||||
<div className="md:w-96">
|
||||
{results.length === 0 ? (
|
||||
<p role="status" className="px-2 text-xs text-fg-muted md:hidden">
|
||||
{t("search.noMatch", { query })}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label={t("search.results")}
|
||||
className="card divide-y divide-border/60 overflow-hidden"
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
||||
<Link
|
||||
to={hrefOf(r)}
|
||||
onClick={() => setQuery("")}
|
||||
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${
|
||||
i === 0 ? "bg-surface-2" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="block text-fg-primary">{r.title}</span>
|
||||
{!r.sectionTitle && (
|
||||
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6">
|
||||
<nav
|
||||
aria-label={t("settingsNav.label")}
|
||||
className="sticky top-0 hidden max-h-[calc(100vh-8rem)] w-56 shrink-0 self-start overflow-y-auto md:block"
|
||||
>
|
||||
{ranked && shown.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
|
||||
)}
|
||||
{GROUPS.map((g) => {
|
||||
const items = shown.filter((x) => x.group === g);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={g} className="mb-3">
|
||||
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t(`settingsNav.group.${g}`)}
|
||||
</p>
|
||||
{items.map(({ id, title, Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
aria-current={id === active.id ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors ${
|
||||
id === active.id
|
||||
? "bg-accent/15 text-accent"
|
||||
: id === first
|
||||
? "bg-surface-3 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<select
|
||||
aria-label={t("settingsNav.label")}
|
||||
value={active.id}
|
||||
onChange={(e) => go(e.target.value)}
|
||||
className="select mb-4 w-full py-1.5 md:hidden"
|
||||
>
|
||||
<div className="flex gap-8">
|
||||
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
|
||||
{searchBox}
|
||||
{searchResults}
|
||||
<nav aria-label={t("settingsNav.label")} className="mt-4 space-y-5">
|
||||
{ranked && shown.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
|
||||
)}
|
||||
{GROUPS.map((g) => {
|
||||
const own = sections.filter((x) => x.group === g);
|
||||
return own.length === 0 ? null : (
|
||||
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
|
||||
{own.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.title}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
const items = shown.filter((x) => x.group === g);
|
||||
if (items.length === 0) return null;
|
||||
const label = t(`settingsNav.group.${g}`);
|
||||
return (
|
||||
<div key={g} role="group" aria-label={label}>
|
||||
<p className="px-2 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{label}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{items.map(({ id, title, Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
aria-current={id === active.id ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
id === active.id
|
||||
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
|
||||
: id === first
|
||||
? "bg-surface-3 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
|
||||
/>
|
||||
<span className="truncate">{title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<h1 className="text-2xl font-semibold text-fg-primary">{active.title}</h1>
|
||||
<p className="mt-1 text-sm text-fg-muted">{active.description}</p>
|
||||
<div className="mb-6 mt-4 border-b border-border" />
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 max-w-5xl flex-1 pb-10">
|
||||
<div className="mb-4 md:hidden">
|
||||
<select
|
||||
aria-label={t("settingsNav.label")}
|
||||
value={active.id}
|
||||
onChange={(e) => go(e.target.value)}
|
||||
className="select w-full py-1.5"
|
||||
>
|
||||
{GROUPS.map((g) => {
|
||||
const own = sections.filter((x) => x.group === g);
|
||||
return own.length === 0 ? null : (
|
||||
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
|
||||
{own.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.title}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<header className="mb-6 flex items-start gap-3 border-b border-border pb-5">
|
||||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-accent">
|
||||
<active.Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-fg-primary">{active.title}</h1>
|
||||
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
|
||||
</div>
|
||||
</header>
|
||||
{active.render()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @file Users.tsx
|
||||
* @description The Users page (`/users`, a sidebar item), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — the account changes: add a user, change a role, disable/enable,
|
||||
* reset a password, give a workspace. Every change asks for the admin's OWN
|
||||
* password again (the server refuses without it); an analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { SettingsSection } from "../components/settings/SettingsRow";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "member"];
|
||||
|
||||
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
|
||||
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
|
||||
|
||||
export function Users() {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
Promise.all([
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => {
|
||||
setUsers(r.users);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
api.users
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([])),
|
||||
]),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "member") void load();
|
||||
}, [me, load]);
|
||||
|
||||
// Local mode, or a member: there is no Users page for them.
|
||||
if (!me || me.role === "member") return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDialog({ kind: "add" })}
|
||||
className="btn-primary"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t("title")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
|
||||
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
|
||||
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
|
||||
{t(`roles.${u.role}`)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="py-1 text-right whitespace-nowrap">
|
||||
<RowAction
|
||||
label={t("changeRole")}
|
||||
disabled={self}
|
||||
onClick={() => setDialog({ kind: "role", user: u })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("resetPassword")}
|
||||
onClick={() => setDialog({ kind: "password", user: u })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("assignWorkspace")}
|
||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={u.disabled ? t("enable") : t("disable")}
|
||||
disabled={self}
|
||||
onClick={() =>
|
||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||
}
|
||||
>
|
||||
{u.disabled ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
|
||||
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{dialog && (
|
||||
<AccountDialog
|
||||
dialog={dialog}
|
||||
users={users ?? []}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={(message) => {
|
||||
toast.success(message);
|
||||
setDialog(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="btn-icon disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One account change: its own fields, then the admin's password to confirm it. */
|
||||
function AccountDialog({
|
||||
dialog,
|
||||
users,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
dialog: Dialog;
|
||||
users: ServerUserUsage[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const { kind, user } = dialog;
|
||||
const name = user?.username ?? "";
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>(user?.role ?? "member");
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "workspace") return;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, [kind]);
|
||||
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(kind === "add"
|
||||
? username.trim().length > 0 && password.length > 0
|
||||
: kind === "password"
|
||||
? password.length > 0
|
||||
: kind === "workspace"
|
||||
? laneId !== null
|
||||
: kind === "role"
|
||||
? role !== user?.role
|
||||
: true);
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (kind === "add") {
|
||||
const r = await api.users.create({ username: username.trim(), password, role }, current);
|
||||
onDone(t("done.created", { name: r.user.username }));
|
||||
} else if (kind === "role" && user) {
|
||||
await api.users.update(user.id, { role }, current);
|
||||
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
|
||||
} else if ((kind === "disable" || kind === "enable") && user) {
|
||||
await api.users.update(user.id, { disabled: kind === "disable" }, current);
|
||||
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
|
||||
} else if (kind === "password" && user) {
|
||||
await api.users.setPassword(user.id, password, current);
|
||||
onDone(t("done.password", { name }));
|
||||
} else if (kind === "workspace" && user && laneId !== null) {
|
||||
await api.users.assignWorkspace(user.id, laneId, current);
|
||||
onDone(t("done.workspace", { name }));
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const titles: Record<DialogKind, string> = {
|
||||
add: t("dialog.add"),
|
||||
role: t("dialog.role", { name }),
|
||||
disable: t("dialog.disable", { name }),
|
||||
enable: t("dialog.enable", { name }),
|
||||
password: t("dialog.password", { name }),
|
||||
workspace: t("dialog.workspace", { name }),
|
||||
};
|
||||
const messages: Partial<Record<DialogKind, string>> = {
|
||||
disable: t("dialog.disableBody", { name }),
|
||||
password: t("dialog.passwordBody", { name }),
|
||||
workspace: t("dialog.workspaceBody", { name }),
|
||||
};
|
||||
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
|
||||
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={titles[kind]}
|
||||
message={messages[kind]}
|
||||
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={kind === "disable"}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
{kind === "add" && (
|
||||
<Field label={t("username")} hint={t("usernameHint")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("username")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{(kind === "add" || kind === "password") && (
|
||||
<Field label={kind === "add" ? t("password") : t("newPassword")} hint={t("passwordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
aria-label={kind === "add" ? t("password") : t("newPassword")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{(kind === "add" || kind === "role") && (
|
||||
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
aria-label={t("role")}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{kind === "workspace" && (
|
||||
<Field label={t("workspace")}>
|
||||
{lanes && lanes.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
aria-label={t("workspace")}
|
||||
>
|
||||
<option value="" />
|
||||
{(lanes ?? []).map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{(l.title || l.cwd) +
|
||||
" — " +
|
||||
(l.owner_id == null
|
||||
? t("noOwner")
|
||||
: t("ownedBy", { name: ownerName(l.owner_id) }))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDiscard } from "../lib/confirm";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
@@ -347,6 +347,29 @@ function ConsoleArea({
|
||||
else onNewSession?.();
|
||||
};
|
||||
|
||||
// A pane header's X: stops that one session, as a one-pane tab's X does —
|
||||
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
|
||||
const stopPane = async (slot: number) => {
|
||||
const run = runOf(slot);
|
||||
if (!run) return;
|
||||
if (
|
||||
run.tool !== "shell" &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: label(run) }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(run.id);
|
||||
ended(slot);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
|
||||
// starts it) in the first free slot, placed on that side of the pane —
|
||||
// unless the workspace changed while it started.
|
||||
@@ -466,6 +489,7 @@ function ConsoleArea({
|
||||
toolLabel={aiTools.label}
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
onStop={(slot) => void stopPane(slot)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("../../lib/api", () => ({
|
||||
RUN_MODEL_CHOICES: [],
|
||||
}));
|
||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
|
||||
PricingLink: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
|
||||
}));
|
||||
|
||||
const GEMINI: AiTool = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Settings.test.tsx
|
||||
* @description The install-wide Settings pages: General and Notifications never
|
||||
* @description The install-wide Settings pages: System shows the server's
|
||||
* runtime, install and database facts; General and Notifications never
|
||||
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
|
||||
* coalesces a burst of activity instead of re-reading per event, a purge takes
|
||||
* a whole number of days and runs only after a dry-run count is confirmed, and
|
||||
@@ -38,9 +39,21 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
|
||||
updates: {
|
||||
status: vi.fn(async () => ({
|
||||
git_repo: false,
|
||||
update_available: true,
|
||||
install_kind: "plugin",
|
||||
repo_root: "/home/u/.claude/plugins/cache/agenthub/9.9.8",
|
||||
local_version: "9.9.8",
|
||||
latest_release: "v9.9.9",
|
||||
})),
|
||||
},
|
||||
settings: {
|
||||
info: (...a: unknown[]) => info(...(a as [])),
|
||||
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
|
||||
autostart: { get: vi.fn(async () => ({ available: false })) },
|
||||
tray: { get: vi.fn(async () => ({ available: false })) },
|
||||
clearData: () => clearData(),
|
||||
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
|
||||
exportData: () => "/api/settings/export",
|
||||
@@ -81,6 +94,47 @@ describe("Settings", () => {
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the server's runtime, install and database facts on System", async () => {
|
||||
info.mockImplementationOnce(
|
||||
async () =>
|
||||
({
|
||||
db: {
|
||||
path: "/data/dashboard.db",
|
||||
size: 5 * 1024 * 1024,
|
||||
counts: {},
|
||||
pragmas: { journal_mode: "wal" },
|
||||
load_stats: { m5: 3, m15: 9, h1: 40 },
|
||||
},
|
||||
hooks: { installed: true, path: "", hooks: {} },
|
||||
server: {
|
||||
version: "1",
|
||||
uptime: 3700,
|
||||
node_version: "v26.0.0",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
cpus: 8,
|
||||
cpu_load: [0.5, 0.25, 0.125],
|
||||
total_mem: 16 * 1024 ** 3,
|
||||
free_mem: 4 * 1024 ** 3,
|
||||
memory: { rss: 200 * 1024 ** 2, heapUsed: 50 * 1024 ** 2, heapLimit: 4 * 1024 ** 3 },
|
||||
ws_connections: 2,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
render(<Settings section="about" />);
|
||||
expect(await screen.findByText("/data/dashboard.db")).toBeInTheDocument();
|
||||
expect(screen.getByText("1h 1m")).toBeInTheDocument();
|
||||
expect(screen.getByText(/linux · x64 · Node.js v26.0.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText("8 cores · load 0.50 / 0.25 / 0.13")).toBeInTheDocument();
|
||||
expect(screen.getByText("200.0 MB RSS · heap 50.0 MB of 4.0 GB")).toBeInTheDocument();
|
||||
expect(screen.getByText("12.0 GB used of 16.0 GB")).toBeInTheDocument();
|
||||
expect(screen.getByText("wal")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 in 5 min · 9 in 15 min · 40 in 1 h")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Claude Code plugin")).toBeInTheDocument();
|
||||
expect(screen.getByText("v9.9.9")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads info once on Data and coalesces a burst of activity", async () => {
|
||||
render(<Settings section="data" />);
|
||||
await screen.findByText("/tmp/x.db");
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* @file SettingsHub.test.tsx
|
||||
* @description The Settings page shell: a path picks its section, `/settings`
|
||||
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
|
||||
* unknown section falls back to General, a disabled tool has no page while an
|
||||
* unknown section falls back to Appearance, a disabled tool has no page while an
|
||||
* installed, enabled catalog tool gets one, the mobile
|
||||
* select navigates, and the search filters the nav, opens the first match on
|
||||
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
|
||||
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
|
||||
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
|
||||
* highlighted, the nav has three groups (General, Agents, Data) with no
|
||||
* one-page group, Git and Snapshots & reports are one page each (their old
|
||||
* paths redirect there), and an unknown section toasts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -31,7 +33,18 @@ vi.mock("../AiToolConfig", () => ({
|
||||
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
|
||||
}));
|
||||
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
|
||||
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
|
||||
vi.mock("../GitCredentialsConfig", () => ({
|
||||
GitCredentialsConfig: () => <div data-testid="git-credentials" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/WorktreeVisibilitySettings", () => ({
|
||||
WorktreeVisibilitySettings: () => <div data-testid="worktree-visibility" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/SnapshotSettings", () => ({
|
||||
SnapshotSettings: () => <div data-testid="snapshot-settings" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/ReportSettings", () => ({
|
||||
ReportSettings: () => <div data-testid="report-settings" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
||||
}));
|
||||
@@ -124,6 +137,10 @@ describe("SettingsHub", () => {
|
||||
["/settings?tool=claude-code", "/settings/agents/claude-code"],
|
||||
["/settings?tool=gemini", "/settings/agents/gemini"],
|
||||
["/settings#ai-tools", "/settings/agents"],
|
||||
["/settings/about", "/settings/system"],
|
||||
["/settings/snapshots", "/settings/reports"],
|
||||
["/settings/worktrees", "/settings/git"],
|
||||
["/settings/git-credentials", "/settings/git"],
|
||||
])("redirects %s to %s", async (from, to) => {
|
||||
renderAt(from);
|
||||
await screen.findByRole("heading", { level: 1 });
|
||||
@@ -170,7 +187,7 @@ describe("SettingsHub", () => {
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
|
||||
expect(links).toEqual(["Pricing"]);
|
||||
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
|
||||
"data-first-match",
|
||||
"true"
|
||||
@@ -234,18 +251,38 @@ describe("SettingsHub", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
|
||||
it("groups the nav into General, Agents and Data, with no one-page group", async () => {
|
||||
renderAt("/settings/data");
|
||||
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
|
||||
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
|
||||
const groups = within(nav())
|
||||
.getAllByRole("group")
|
||||
.map((g) => [g.getAttribute("aria-label"), within(g).getAllByRole("link").length]);
|
||||
expect(groups.map(([name]) => name)).toEqual(["General", "Agents", "Data"]);
|
||||
for (const [, count] of groups) expect(count).toBeGreaterThan(1);
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
|
||||
expect(links).not.toContain("General");
|
||||
expect(links.slice(0, 3)).toEqual(["Appearance", "Notifications", "System"]);
|
||||
expect(links).toEqual(expect.arrayContaining(["AI Proxy", "Pricing", "Git"]));
|
||||
expect(links).not.toContain("About");
|
||||
expect(links).not.toContain("Worktrees");
|
||||
expect(links).not.toContain("Pet");
|
||||
});
|
||||
|
||||
it("shows git logins and worktree suggestions on one Git page", async () => {
|
||||
renderAt("/settings/git");
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows snapshots and scheduled reports on one page", async () => {
|
||||
renderAt("/settings/reports");
|
||||
await screen.findByRole("heading", { level: 1, name: "Snapshots & reports" });
|
||||
expect(screen.getByTestId("snapshot-settings")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("report-settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scrolls to and highlights the anchor the URL names", async () => {
|
||||
renderAt("/settings/data#data-row");
|
||||
const row = await screen.findByTestId("settings-data");
|
||||
@@ -272,7 +309,9 @@ describe("SettingsHub", () => {
|
||||
const off = subscribeToasts((i) => (seen = i));
|
||||
renderAt("/settings/nope");
|
||||
await screen.findByTestId("pet-settings");
|
||||
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
|
||||
const msg = seen.find((x) => x.message.includes("nope"))?.message ?? "";
|
||||
expect(msg).toContain("Appearance");
|
||||
expect(msg).not.toContain("General");
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @file Users.test.tsx
|
||||
* @description The Users page: local mode and a member are sent home; an
|
||||
* analyst reads the table with no account controls; an admin adds a user only
|
||||
* with their own password typed (the confirm stays disabled until then), sees
|
||||
* a wrong password reported in the dialog, and cannot change their own role or
|
||||
* disable themselves.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { AuthState, ServerUserUsage } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
const usage = { cost: 0, tokens: 0 };
|
||||
const row = (id: number, username: string, role: ServerUserUsage["role"]): ServerUserUsage => ({
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: usage,
|
||||
last30d: usage,
|
||||
});
|
||||
const users = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
assignWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
users,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Users } from "../Users";
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/users"]}>
|
||||
<Routes>
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/" element={<div data-testid="home" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(users)) fn.mockReset();
|
||||
users.list.mockResolvedValue({
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "member")],
|
||||
});
|
||||
users.audit.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
describe("Users page", () => {
|
||||
it("sends local mode and a member home", async () => {
|
||||
authState = { mode: "local" };
|
||||
renderPage();
|
||||
expect(await screen.findByTestId("home")).toBeTruthy();
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
|
||||
renderPage();
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
expect(users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an analyst the accounts with no controls", async () => {
|
||||
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
|
||||
renderPage();
|
||||
expect(await screen.findByText("mia")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Add user" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Reset password" })).toBeNull();
|
||||
expect(screen.getByText("Analysts can view accounts but not change them.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a user only with the admin's own password", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.create.mockResolvedValue({
|
||||
user: { id: 9, username: "neo", role: "member", disabled: false },
|
||||
});
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
await ue.click(await screen.findByRole("button", { name: "Add user" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await ue.type(screen.getByLabelText("Username"), "neo");
|
||||
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add user"
|
||||
)!;
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(false);
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
{ username: "neo", password: "neo-pass-123", role: "member" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the dialog and keeps it open", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.setPassword.mockRejectedValue(
|
||||
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
|
||||
);
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
await ue.click(screen.getAllByRole("button", { name: "Reset password" })[1]!);
|
||||
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
|
||||
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
"textContent",
|
||||
"Your password is wrong."
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(users.setPassword).toHaveBeenCalledWith(2, "mia-new-pass-1", "wrong");
|
||||
});
|
||||
|
||||
it("never offers an admin a role change or disable on their own row", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
const [ownRole, miaRole] = screen.getAllByRole("button", { name: "Change role" });
|
||||
const [ownLock, miaLock] = screen.getAllByRole("button", { name: "Disable" });
|
||||
expect(ownRole!.hasAttribute("disabled")).toBe(true);
|
||||
expect(ownLock!.hasAttribute("disabled")).toBe(true);
|
||||
expect(miaRole!.hasAttribute("disabled")).toBe(false);
|
||||
expect(miaLock!.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -758,6 +758,21 @@ describe("split panes", () => {
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
});
|
||||
|
||||
it("a pane's X stops just its session, without taking it out of the tab first", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
await ctrlClick(screen.getByTestId("console-tab-2"));
|
||||
|
||||
withRuns([live(0), live(2)]);
|
||||
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledTimes(1);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1");
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
|
||||
expect(screen.queryByTestId("console-tab-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a remembered pane whose session is gone", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
|
||||
@@ -2765,137 +2765,71 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="pricing"
|
||||
<div
|
||||
class="card flex items-center justify-between gap-4 p-5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 mb-4"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-dollar-sign w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="2"
|
||||
y2="22"
|
||||
/>
|
||||
<path
|
||||
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
|
||||
/>
|
||||
</svg>
|
||||
Model Pricing
|
||||
<button
|
||||
aria-label="How model pricing works"
|
||||
class="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mt-0.5"
|
||||
>
|
||||
Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
<h3
|
||||
class="flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
||||
<svg
|
||||
class="lucide lucide-dollar-sign h-4 w-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-rotate-ccw w-3 h-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
||||
/>
|
||||
<path
|
||||
d="M3 3v5h5"
|
||||
/>
|
||||
</svg>
|
||||
Reset Defaults
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
/>
|
||||
<path
|
||||
d="M12 5v14"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="2"
|
||||
y2="22"
|
||||
/>
|
||||
<path
|
||||
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
|
||||
/>
|
||||
</svg>
|
||||
Model pricing
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Prices for every vendor's models live on one page.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="card mt-4 px-4 py-6 text-center text-xs text-fg-muted"
|
||||
<a
|
||||
class="btn-ghost shrink-0 text-xs"
|
||||
href="/settings/pricing#pricing-claude"
|
||||
>
|
||||
No Claude model prices. Reset Defaults restores them.
|
||||
</p>
|
||||
</section>
|
||||
Open Pricing
|
||||
<svg
|
||||
class="lucide lucide-arrow-right h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
/>
|
||||
<path
|
||||
d="m12 5 7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7142,333 +7076,404 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<div
|
||||
class="animate-fade-in space-y-8"
|
||||
>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
<div
|
||||
class="space-y-6 scroll-mt-24"
|
||||
id="about"
|
||||
>
|
||||
<div
|
||||
class="mb-4"
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="autostart"
|
||||
>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="autostart"
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Startup
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Startup
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
Server runtime information.
|
||||
</p>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"
|
||||
>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-server w-4 h-4 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="8"
|
||||
rx="2"
|
||||
ry="2"
|
||||
width="20"
|
||||
x="2"
|
||||
y="2"
|
||||
/>
|
||||
<rect
|
||||
height="8"
|
||||
rx="2"
|
||||
ry="2"
|
||||
width="20"
|
||||
x="2"
|
||||
y="14"
|
||||
/>
|
||||
<line
|
||||
x1="6"
|
||||
x2="6.01"
|
||||
y1="6"
|
||||
y2="6"
|
||||
/>
|
||||
<line
|
||||
x1="6"
|
||||
x2="6.01"
|
||||
y1="18"
|
||||
y2="18"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Show AgentHub icon in the status area
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-label="Show AgentHub icon in the status area"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Quit AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
data-testid="quit-agenthub"
|
||||
type="button"
|
||||
>
|
||||
Quit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="server"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Server
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Release
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.5.12 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary font-mono"
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
v
|
||||
</p>
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.5.11 (rebuild client to match)
|
||||
</p>
|
||||
<span
|
||||
class="text-sm text-fg-primary font-mono text-xs"
|
||||
>
|
||||
v
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-clock w-4 h-4 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<polyline
|
||||
points="12 6 12 12 16 14"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Uptime
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary"
|
||||
>
|
||||
0m
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-cpu w-4 h-4 text-status-success"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
<rect
|
||||
height="16"
|
||||
rx="2"
|
||||
width="16"
|
||||
x="4"
|
||||
y="4"
|
||||
/>
|
||||
<rect
|
||||
height="6"
|
||||
rx="1"
|
||||
width="6"
|
||||
x="9"
|
||||
y="9"
|
||||
/>
|
||||
<path
|
||||
d="M15 2v2"
|
||||
/>
|
||||
<path
|
||||
d="M15 20v2"
|
||||
/>
|
||||
<path
|
||||
d="M2 15h2"
|
||||
/>
|
||||
<path
|
||||
d="M2 9h2"
|
||||
/>
|
||||
<path
|
||||
d="M20 15h2"
|
||||
/>
|
||||
<path
|
||||
d="M20 9h2"
|
||||
/>
|
||||
<path
|
||||
d="M9 2v2"
|
||||
/>
|
||||
<path
|
||||
d="M9 20v2"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
>
|
||||
Node.js
|
||||
</p>
|
||||
0m
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary font-mono"
|
||||
>
|
||||
v22.0.0
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-globe w-4 h-4 text-syntax-keyword"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
|
||||
/>
|
||||
<path
|
||||
d="M2 12h20"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Platform
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary"
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
linux
|
||||
</p>
|
||||
<span
|
||||
class="text-sm text-fg-primary font-mono text-xs"
|
||||
>
|
||||
linux
|
||||
· x64
|
||||
· Node.js
|
||||
v22.0.0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-wifi w-4 h-4 text-status-warning"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 20h.01"
|
||||
/>
|
||||
<path
|
||||
d="M2 8.82a15 15 0 0 1 20 0"
|
||||
/>
|
||||
<path
|
||||
d="M5 12.859a10 10 0 0 1 14 0"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 16.429a5 5 0 0 1 7 0"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
WS Clients
|
||||
CPU
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary"
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
0
|
||||
</p>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
1 cores · load 0.00 / 0.00 / 0.00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Process memory
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0 B RSS · heap 0 B of 0 B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Live connections
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Browser tabs and tools receiving live updates
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="install"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Installation
|
||||
</h3>
|
||||
<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"
|
||||
>
|
||||
Unknown
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="database"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Database
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Database file
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="font-mono break-all"
|
||||
>
|
||||
/tmp/test.db
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0 B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Journal mode
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary font-mono text-xs"
|
||||
>
|
||||
wal
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Events recorded
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0 in 5 min · 0 in 15 min · 0 in 1 h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -338,6 +338,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
|
||||
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
|
||||
},
|
||||
tray: {
|
||||
get: r({ available: true, enabled: true, running: true }),
|
||||
set: r({ available: true, enabled: false, running: false }),
|
||||
},
|
||||
privacy: {
|
||||
get: r({
|
||||
redact_secrets: false,
|
||||
@@ -639,11 +643,11 @@ describe("screen snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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. Its prices live on the Pricing page; the Overview only links there.
|
||||
describe("Claude Code's AgentHub settings", () => {
|
||||
it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => {
|
||||
it("shows hooks and CLAUDE_HOME on the Overview only", async () => {
|
||||
const dashboard = render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<Settings />
|
||||
@@ -662,12 +666,14 @@ describe("Claude Code's AgentHub settings", () => {
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
for (const id of ["hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
expect(container.querySelector("section#pricing")).toBeNull();
|
||||
expect(container.querySelector('a[href="/settings/pricing#pricing-claude"]')).not.toBeNull();
|
||||
fireEvent.click(screen.getByTestId("cc-tab-skills"));
|
||||
await settle();
|
||||
expect(container.querySelector("section#pricing")).toBeNull();
|
||||
expect(container.querySelector("section#hooks")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+54
-13
@@ -85,11 +85,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions and admins' account changes (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `PATCH /api/users/:id` (`{role?, disabled?}`), `POST /api/users/:id/password` (`{password}`; that user's logins end) and `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`, `404 ENOUSER`/`ENOLANE`; an analyst gets `403 EREADONLY`, a member `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], blockPercent}`. It never includes a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or the Settings About panel to confirm client and server builds match after deploy.
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -580,6 +582,7 @@ that file.
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>][&ref=<branch>]
|
||||
GET /api/lanes/:id/git/blame?path=<path>
|
||||
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
|
||||
PUT /api/lanes/:id/git/file {path, content, baseSha}
|
||||
GET /api/lanes/:id/git/raw?path=<image>[&rev=work|HEAD|<sha>]
|
||||
@@ -596,10 +599,16 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
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`. `ref=` reads another branch's history instead of HEAD's
|
||||
`400 EBADPATH` — with each `parents` REWRITTEN to the file's own previous
|
||||
commit (`--parents`), so the file's graph is one joined-up line. `ref=` reads another branch's history instead of HEAD's
|
||||
(where the panel's Cherry-pick finds its commits): a local or
|
||||
remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
- **`/git/blame`** → `{available, path, commits: {<sha>: {short, author,
|
||||
date, summary}}, lines: [<sha>|null]}` — `git blame --porcelain` of the
|
||||
working-tree file (Annotate); `null` = a line not committed yet. Same
|
||||
lexical guard as `/git/file`, `403 ESECRET`, `400 ETOOBIG` over 1 MB,
|
||||
`400 ENOFILE`; an untracked file answers `{available:false}`.
|
||||
- **`PUT /git/file`** saves the whole-file editor (same-origin guarded;
|
||||
`server/lib/lane-file-write.js`). Same path guards as the read, plus: the
|
||||
file must already exist, be text and within the 1 MB cap, and `baseSha`
|
||||
@@ -689,12 +698,15 @@ 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
|
||||
— on the **staged** diff of the workspace's cwd (or `?session=` worktree) — or,
|
||||
with a body `{"paths": [...]}` (the panel's ticked files), on those files'
|
||||
working-tree diff against HEAD, untracked ones included — 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,
|
||||
`400 ENOSTAGED` with nothing staged (or no readable ticked file), `400 EBADPATH`
|
||||
for a path git does not report changed, `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
|
||||
@@ -718,7 +730,7 @@ cannot collide on `index.lock`.
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
@@ -730,12 +742,24 @@ cannot collide on `index.lock`.
|
||||
| `cherry-pick` | `git cherry-pick <sha>` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
|
||||
| `revert` | `git revert --no-edit <sha>` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
|
||||
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": "<HEAD the user saw>"}` |
|
||||
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands | `{"message": "...", "sha": "<HEAD>"}` |
|
||||
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands, or with `paths` exactly those files (`--only`; an empty list rewords and needs a message) | `{"message": "...", "sha": "<HEAD>", "paths": ["..."]}` |
|
||||
| `ignore` | appends one anchored, escaped `/<path>` line per path to the WORKSPACE's own `.gitignore` (created if missing, never a line it already has) — untracked paths only (`400 ETRACKED`), never through a symlink or onto a non-regular file (`400 EBADPATH`) | `{"paths": ["..."]}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
```
|
||||
|
||||
- `commit`/`amend` also take `hunks` — `{"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}`
|
||||
with `context` — to commit only those change blocks of a tracked,
|
||||
non-renamed file (alone or beside whole `paths`): the commit is built in a
|
||||
temporary index from git's own fresh diff (never text from the request) and
|
||||
the real index entries of the committed paths are reset to the new HEAD
|
||||
afterwards, the rest left as an unstaged change. A hunk no longer in the diff
|
||||
is `409 EHUNKGONE`, an untracked path `400 EUNTRACKED`, a malformed entry
|
||||
`400 EBADPATH`.
|
||||
- `commit`/`amend` with `paths` refuse a conflicted path (`409 ECONFLICTS`) —
|
||||
a merge is finished by `merge-commit` — and any path git does not report
|
||||
changed (`400 EBADPATH`).
|
||||
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
|
||||
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
|
||||
running agent. There is deliberately no rebase, hard reset or stash here —
|
||||
@@ -843,7 +867,7 @@ DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
|
||||
|
||||
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
|
||||
this install; the **Git credentials** tab of Settings
|
||||
(`/settings/git-credentials`) and the git bar's login form both write
|
||||
(`/settings/git`) and the git bar's login form both write
|
||||
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
|
||||
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
|
||||
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
|
||||
@@ -1786,7 +1810,24 @@ GET /api/settings/autostart
|
||||
PUT /api/settings/autostart
|
||||
```
|
||||
|
||||
Settings → About's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
|
||||
Settings → System's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
|
||||
|
||||
#### Status-area icon
|
||||
|
||||
```http
|
||||
GET /api/settings/tray
|
||||
PUT /api/settings/tray
|
||||
```
|
||||
|
||||
Settings → System's **Show AgentHub icon in the status area** switch (`server/lib/tray.js`). `GET` answers `{ available, enabled, running }`: `available` is false in server mode and on a Linux machine with no graphical session (`DISPLAY`/`WAYLAND_DISPLAY`, read from `systemctl --user show-environment` too), `running` is true while the icon process heartbeats. `PUT { enabled: boolean }` (same-origin only) removes or writes `<data dir>/runtime/tray-off` and starts or stops the icon process at once; it never touches the server. On by default. `400 EBADVALUE` for a non-boolean.
|
||||
|
||||
#### Quit AgentHub
|
||||
|
||||
```http
|
||||
POST /api/settings/quit
|
||||
```
|
||||
|
||||
Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `<data dir>/runtime/stopped`, kills every `agenthub-lane-*` tmux session (their runs are recorded `killed`), answers `{ ok: true, killed }` and then stops the server: through `systemctl --user stop --no-block agenthub.service` / `launchctl bootout` when AgentHub's unit is running it (a SIGTERM would be restarted by `Restart=always`/`KeepAlive`), else by SIGTERM. While the flag exists the plugin bootstrap starts no server; every server clears it when it starts listening, so a boot/login through an enabled autostart unit brings the dashboard back.
|
||||
|
||||
---
|
||||
|
||||
@@ -2068,7 +2109,7 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
|
||||
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>
|
||||
```
|
||||
|
||||
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch).
|
||||
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch). A Claude request whose whole prompt (input + cache read + cache write) is above 100k tokens is also counted into the bucket's `long_*_tokens` (returned on each `breakdown` entry) and that part is priced at the rule's `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5: 5x; cache rates scale by the input ratio; a rule with no long rates prices it at its standard rates).
|
||||
|
||||
`from` / `to` (local dates, inclusive, read with `tz_offset`; either end may be left open) narrow `total_cost`, `breakdown`, `by_tool`, `feature_costs` and `unpriced_models` to the tokens SPENT on those days (the same split as `daily_costs`). `daily_costs` itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or `from` after `to` is `400 INVALID_INPUT`.
|
||||
|
||||
@@ -2083,7 +2124,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
|
||||
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"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **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.
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor"|"grok"|"kimi"|"opencode", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], 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"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **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). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). **Grok Build** reads the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json` (default `~/.grok`) and sends it only to `cli-chat-proxy.grok.com`'s `GET /v1/billing?format=credits` (`wk` = `creditUsagePercent`, `mo` = `used / monthlyLimit`). **Kimi Code** reads `$KIMI_CODE_HOME/credentials/kimi-code.json` (default `~/.kimi-code`) and sends it only to `api.kimi.com`'s `GET /coding/v1/usages` (`5h`/`wk`/`mo` from `limit_5h`/`limit_7d`/`limit_month_total`). Both are used only while valid and never refreshed (`error: "ETOKENEXPIRED"`; a Kimi refresh would rotate the CLI's refresh token and sign it out); no file, no entry. **OpenCode Go** (`id: "opencode"`, `plan: "Go"`) reads opencode's own `opencode-go` API key — `auth.json`, else the `credential` table of `opencode.db`, under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — and sends it only to `opencode.ai`'s `GET /zen/go/v1/usage` (`5h`/`wk`/`mo` meters); an opencode with no Go key has no entry. These readers follow Orca's (MIT). All three cached like Claude. `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType`, Grok's `subscriptionTier` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). 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.
|
||||
#### Analytics
|
||||
|
||||
```http
|
||||
@@ -2307,7 +2348,7 @@ Pulls history from **every enabled** source sequentially (one SSH connection at
|
||||
|
||||
#### Push ingest (`POST /api/hooks/ingest-batch`)
|
||||
|
||||
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Data → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
|
||||
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Database → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
|
||||
|
||||
```http
|
||||
POST /api/hooks/ingest-batch
|
||||
@@ -2567,7 +2608,7 @@ DELETE /api/pets/:id Delete a pet (its folder under the data di
|
||||
|
||||
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); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves. Each row keeps its own pace with a longer hold on its last frame (idle ~220 ms a frame, then ~1.4 s). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. 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 one of those sizes (`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.
|
||||
|
||||
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||
|
||||
|
||||
+3
-1
@@ -99,7 +99,9 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
|
||||
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `kill <pid>` stop command. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once) |
|
||||
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
|
||||
| `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `<data dir>/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag |
|
||||
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
|
||||
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
|
||||
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
|
||||
+5
-2
@@ -398,7 +398,7 @@ CREATE TABLE notifications (
|
||||
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
||||
|
||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. A Claude entry's `*_above_100k_tokens` input/output rates fill `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5); Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_pricing (
|
||||
@@ -411,6 +411,8 @@ CREATE TABLE model_pricing (
|
||||
cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0, -- 1h-ephemeral cache-write tier
|
||||
fast_input_per_mtok REAL NOT NULL DEFAULT 0, -- fast-mode premium rates
|
||||
fast_output_per_mtok REAL NOT NULL DEFAULT 0,
|
||||
long_input_per_mtok REAL NOT NULL DEFAULT 0, -- prompt above 100k tokens
|
||||
long_output_per_mtok REAL NOT NULL DEFAULT 0,
|
||||
-- Time-limited introductory (promo) rates. When intro_until is set, usage on
|
||||
-- or before that date (YYYY-MM-DD) is priced at the intro_* rates and usage
|
||||
-- after it at the standard rates. All 0 / NULL = no promo.
|
||||
@@ -434,6 +436,7 @@ CREATE TABLE model_pricing (
|
||||
| `input_per_mtok` / `output_per_mtok` | REAL | NO | Standard input / output rate per 1M tokens |
|
||||
| `cache_read_per_mtok` / `cache_write_per_mtok` / `cache_write_1h_per_mtok` | REAL | NO | Cache read + 5m/1h cache-write rates |
|
||||
| `fast_input_per_mtok` / `fast_output_per_mtok` | REAL | NO | Fast-mode premium rates (0 = no premium) |
|
||||
| `long_input_per_mtok` / `long_output_per_mtok` | REAL | NO | Rates for a request whose whole prompt (input + cache read + cache write) is above 100k tokens (`LONG_CONTEXT_TOKENS` in `server/lib/pricing-constants.js`); the cache rates scale by `long_input / input`. 0 = none. They price `token_usage`'s `long_*_tokens` subsets — the part of each count spent by such requests, kept at its high-water mark (`stmts.raiseLongTokens`; the exact reconcile path uses `setLongTokens`) |
|
||||
| `intro_*_per_mtok` | REAL | NO | Introductory (promo) rates, mirroring the standard fields |
|
||||
| `intro_until` | TEXT | YES | Promo cutoff `YYYY-MM-DD`. Usage on/before it uses the intro rates; NULL = no promo. Editable per-rule in Settings |
|
||||
| `source` | TEXT | YES | `sync` = kept at LiteLLM's rate by the automatic sync (shown **Auto** in Settings), `user` = edited in Settings and never touched by the sync, NULL = from before this column (adopted as `sync` only while it equals the list) |
|
||||
@@ -509,7 +512,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
|
||||
|
||||
### token_usage_hourly
|
||||
|
||||
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier and the eight count columns, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
|
||||
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
|
||||
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
||||
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
|
||||
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
||||
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
||||
|
||||
# Pricing and saved settings
|
||||
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ Comprehensive guide to integrating with Claude Code's hook system for real-time
|
||||
|
||||
Claude Code provides a hook system that allows external tools to receive real-time events during agent execution. Agent Dashboard uses these hooks to capture session lifecycle, tool executions, and notifications.
|
||||
|
||||
> **Privacy (Settings → Data → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
|
||||
> **Privacy (Settings → Database → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
|
||||
|
||||
> **Cursor (informational):** Live hooks fire from Claude Code. **Cursor** sessions that only exist as JSONL under `~/.claude` (Cursor uses the same paths locally) are still counted — they appear via startup import, continuous project sync, or remote SSH sync, not via hooks.
|
||||
|
||||
|
||||
+80
-24
@@ -409,7 +409,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
- **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. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2); a drop that would pass four panes reorders instead (`joinGroup`). 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.
|
||||
- **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, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. 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.
|
||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
@@ -560,8 +560,8 @@ to spawn a sibling worktree workspace on it, and moving this pane's HEAD under a
|
||||
working agent is not. **New workspace here** goes through the normal Add-workspace flow
|
||||
with the fields prefilled — one provisioning path for every workspace, not a second
|
||||
one hidden in the git bar. There is no **Commit** here: committing is the
|
||||
overview's staged-only box below, the one commit path, and it only ever takes
|
||||
what was staged.
|
||||
overview's commit box below, the one commit path, and it only ever takes the
|
||||
files ticked there.
|
||||
|
||||
**Publish…** is the one button whose destination the user types, so the server
|
||||
keeps it narrow: it only gives a remote to a repo that has none (it never
|
||||
@@ -687,10 +687,16 @@ reads the workspace's git surface in five modes:
|
||||
that block alone. The single-file view offers the same bar.
|
||||
A new line's NUMBER is a button too: it opens a comment box under the diff,
|
||||
and **Paste into pane** pastes `<path>:<line> — <text>` into this
|
||||
workspace's pane focused last (`client/src/lib/panePaste.ts`, the same
|
||||
workspace's pane focused last — clicked into, or focused through a tab,
|
||||
Alt+1–4 or the sidebar (`client/src/lib/panePaste.ts`, the same
|
||||
bracketed `term.paste()` an image drop uses) — nothing is sent until the
|
||||
user presses Enter in the pane; with no pane of the workspace open the box
|
||||
says so and keeps the text.
|
||||
In the file editor, a text selection shows Cursor's **Add to Chat** chip
|
||||
above it, which pastes `@<path>#L<from>-<to>` (Claude Code's line-range
|
||||
mention; `#L<n>` for one line — without the `L` Claude Code attaches the
|
||||
whole file) the same way. Other tools (Codex, …) get the same text as a
|
||||
plain hint.
|
||||
- **One file** — clicking a file's path in any list opens just that file.
|
||||
**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
|
||||
@@ -743,27 +749,67 @@ reason — *where you are* (mode, refresh) above, *how you are looking at it*
|
||||
(range, layout, context, whole file) below — because one row wrapped mid-group
|
||||
in every panel narrow enough to sit beside a console.
|
||||
|
||||
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
|
||||
one file means nothing unless a commit can take the index as it stands, so the
|
||||
overview grows a commit box the moment anything is staged — above the list and
|
||||
pinned while it scrolls: the message (and its suggest button), the commit button, a **Stage all** that stages every listed file
|
||||
the index lacks in one call and an **Unstage all** that unstages every staged one — one row when the
|
||||
panel has room, the buttons wrapping under the message when it is narrow. The commit sends
|
||||
`only: "staged"`, never `add -A`, and the box is absent with a clean index because
|
||||
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. Each group is a folder tree like
|
||||
**Per-file writes are the panel's, per-repo writes are the git bar's.** The
|
||||
commit is JetBrains' (PhpStorm's Commit window without its staging area): in
|
||||
the `work` range the overview lists **Changes** (every tracked change, staged
|
||||
or not) above **Unversioned Files** (untracked), and every file, folder and
|
||||
group header has a checkbox. **Commit** takes exactly the ticked files'
|
||||
working-tree content — `paths`, `git commit --only` on the server — whatever
|
||||
the index holds, so a staged but unticked file stays staged and uncommitted. A
|
||||
tracked change arrives ticked, an unversioned one unticked, and a file the
|
||||
user unticked stays unticked across polls. The commit box sits above the list
|
||||
and stays pinned while it scrolls: the message (and its ✨ suggest button,
|
||||
which reads the ticked files), **Amend** (folds the ticked files into the last
|
||||
commit; nothing ticked rewords it), **Commit (n)** and **Commit and Push**
|
||||
(the same commit, then the git bar's `push`; a failed push leaves the commit
|
||||
made and says so). It is hidden while a merge, cherry-pick or revert is open —
|
||||
a partial commit cannot finish one, **Complete** does. 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`.
|
||||
|
||||
**A commit can take part of a file**, like JetBrains' diff viewer: in Review
|
||||
(and in the editor's bar under each block) every change block of a tracked
|
||||
file has its own checkbox beside its Undo. Unticking one makes the file's box
|
||||
partial (indeterminate) everywhere it is drawn; ticking a block of an unticked
|
||||
file takes just that block; unticking every block unticks the file. A block
|
||||
is remembered by its content (its -/+ lines and how many identical blocks come
|
||||
before it), so a re-read or an edit elsewhere in the file keeps the choice. At
|
||||
Commit the panel re-reads each partial file at the default context and sends
|
||||
its ticked blocks as `hunks` (`{"<path>": [{hunk, blocks}]}`); the server builds
|
||||
the commit in a temporary index from git's own fresh diff and resets the real
|
||||
index entries of those paths to the new HEAD, so what was left out stays an
|
||||
unstaged change in the working tree. The review header of each file carries
|
||||
the same checkbox and **Rollback** (discard) instead of stage/unstage.
|
||||
|
||||
Files are coloured like JetBrains in the Changes list and the Files tree:
|
||||
unversioned orange (`--kept`), added green, modified and renamed blue
|
||||
(`--diff-mod`), deleted grey (struck through in the tree), conflicted red,
|
||||
ignored dimmed.
|
||||
|
||||
An unversioned row offers **Add to VCS** (`stage`, and the file is ticked).
|
||||
Right-click on a file adds **Commit File…** (that file alone ticked, the
|
||||
message box focused), **Jump to Source** (the whole file), **Copy relative
|
||||
path**, **Copy as Patch to Clipboard** (the file's diff as `git apply` takes
|
||||
it), and for an unversioned file **Add to .gitignore** (`ignore`: one anchored,
|
||||
escaped `/path` line appended to the workspace's own `.gitignore`), **Unstage**
|
||||
for a file something else left staged in the index, **Show History** (also on
|
||||
a Files-tree file: `/git/log?file=`, parents rewritten so the graph joins up;
|
||||
a commit opens that file as that commit changed it, and **Whole branch** goes
|
||||
back to the branch's history), and **Refresh**. In Review and in an open file
|
||||
the toolbar's ▲/▼ (and **F7** / **Shift+F7**) jump to the previous / next
|
||||
difference — the editor's own changed blocks, else each block's first diff
|
||||
row — and in an open file ◀/▶ open the previous / next file of the list it
|
||||
was opened from. **Annotate** on an open working file adds a gutter naming
|
||||
each line's last commit (`GET /git/blame`; date and author, the subject on
|
||||
hover, a click opens that commit; a line not committed yet says so).
|
||||
**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. 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.
|
||||
(`src/lib`), each folder row showing its file count and folding on a click;
|
||||
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
|
||||
reader's scroll position is worse than a slightly stale view, and those are the
|
||||
@@ -1499,6 +1545,16 @@ curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "wip: staged only", "only": "staged"}'
|
||||
|
||||
# Exactly these files (the Changes panel's ticked ones), untracked included
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "fix: two files", "paths": ["src/a.ts", "notes.md"]}'
|
||||
|
||||
# Add an untracked file to the workspace's .gitignore
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/ignore \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paths": ["scratch.log"]}'
|
||||
|
||||
# Irreversible, so `confirm` is required and the path must be one git
|
||||
# already reports as changed
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
|
||||
@@ -1753,7 +1809,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
|
||||
|
||||
## Workspaces on any AI tool
|
||||
|
||||
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — ten built in: Claude Code, Codex, Gemini CLI, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Oh My Pi, Hermes and OpenClaw —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
|
||||
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
|
||||
|
||||
A catalog file:
|
||||
|
||||
|
||||
+10
-6
@@ -66,10 +66,12 @@ Internal build — all rights reserved.
|
||||
images dragged onto a terminal or a prompt box are uploaded and attached by
|
||||
their saved path.
|
||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
||||
untracked worktrees a workspace's Add folder suggests.
|
||||
grouped General (Appearance — language, theme, pet — / Notifications /
|
||||
System) / Agents (AI tools, one page per installed AI tool, AI Proxy) / Data
|
||||
(Database / Pricing / Snapshots & reports / Git — logins and which untracked
|
||||
worktrees a workspace's Add folder suggests), a search box (Ctrl+F) over every
|
||||
section whose results deep-link a row's sub-tab and anchor. Model prices live
|
||||
only on Pricing; a tool's page links there.
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||
@@ -82,7 +84,9 @@ Internal build — all rights reserved.
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
Per-browser only.
|
||||
It also picks the workspace terminals' font: JetBrains Mono (default), the
|
||||
rounded Maple Mono, or the browser's own fixed-width font (which may lack some
|
||||
Vietnamese letters). Per-browser only.
|
||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||
Pop, Alert — off by default) played with every notification shown, and each
|
||||
notification type can pick its own.
|
||||
@@ -209,7 +213,7 @@ agenthub service uninstall # disable and remove it
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. The same is a switch in the dashboard — Settings → About →
|
||||
once yourself. The same is a switch in the dashboard — Settings → System →
|
||||
**Start AgentHub automatically** (off by default; it enables or disables the
|
||||
unit and never stops the running server). Plugin installs get it only once
|
||||
that switch is on — see
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ agenthub service uninstall # disable và gỡ bỏ
|
||||
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
|
||||
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
|
||||
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
|
||||
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
|
||||
Trong dashboard cũng có công tắc tương ứng — Settings → Hệ thống → **Tự động khởi
|
||||
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
|
||||
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
|
||||
bật — xem
|
||||
|
||||
+56
-2
@@ -131,7 +131,7 @@ waits on an install:
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags, then `tsc -b` and `vite build` run by the bootstrap's own `node` — the client's `build` script without `npm run`), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
9. **Server** — when the user has turned **Start AgentHub automatically** on
|
||||
(Settings → About; OFF by default), install/refresh the
|
||||
(Settings → System; OFF by default), install/refresh the
|
||||
`agenthub.service` unit on Linux — on macOS with a GUI login, the
|
||||
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
|
||||
of a bare spawn, so the server is supervised and comes back after a reboot
|
||||
@@ -241,9 +241,43 @@ cushion — one build deep, because a full copy of this repo is ~130 MB. Outside
|
||||
a git checkout the parent directory holds the user's own work. Best effort
|
||||
throughout: a directory that will not delete is left alone, never an error.
|
||||
|
||||
### Quit
|
||||
|
||||
Settings → System → **Quit AgentHub** or `agenthub stop` (issue #65,
|
||||
`server/lib/quit.js`, `POST /api/settings/quit`) closes every workspace
|
||||
terminal and stops the server, through the service manager when the unit runs
|
||||
it. It writes `<data dir>/runtime/stopped` first: while that file exists
|
||||
`fastPathOk` is true with no server and the worker starts none (`--force`, i.e.
|
||||
`/agenthub-update`, overrides it; a stale unit is still repaired, not started).
|
||||
Any server that starts clears it — `agenthub start`, which in a plugin install
|
||||
runs this bootstrap, or a boot/login through an enabled autostart unit — so
|
||||
autostart keeps working across a reboot.
|
||||
|
||||
### Status-area icon
|
||||
|
||||
Like 9router's, AgentHub puts an icon beside the clock — GNOME's top bar, the
|
||||
macOS menu bar, the Windows notification area (`server/lib/tray.js`,
|
||||
`scripts/tray.js`). It is its own process, drawn by the `systray2` package's
|
||||
helper binary (installed with the other runtime dependencies), so a server
|
||||
restart or a Quit never takes it away: after a Quit its **Start AgentHub** is
|
||||
the way back. A production server starts it once it is listening and checks
|
||||
every minute (a Linux server started at boot has no `DISPLAY` until someone
|
||||
logs in; three quick deaths in a row and that server stops trying). It is on by
|
||||
default; **Hide this icon** in its menu and Settings → System → **Show AgentHub
|
||||
icon in the status area** write `<data dir>/runtime/tray-off`, and only that
|
||||
switch removes it again. One icon per data dir: `<data dir>/runtime/tray.json`
|
||||
holds its pid and install root and is touched every 5 s as a heartbeat — a
|
||||
stale file is never trusted, so a pid reused after a reboot is never
|
||||
signalled. A server replaces an icon from another install root (the previous
|
||||
plugin build) once at start. Never in server mode, nor from a development
|
||||
server. The icon starts at login only when the dashboard does (start at login
|
||||
below); there is no separate autostart entry. Verified on Ubuntu GNOME (X11);
|
||||
on macOS `systray2` ships only an x86_64 helper (Apple Silicon runs it through
|
||||
Rosetta 2, else no icon appears), and Windows is untested.
|
||||
|
||||
### Auto-start on boot
|
||||
|
||||
Opt-in, and off by default: Settings → About → **Start AgentHub automatically**
|
||||
Opt-in, and off by default: Settings → System → **Start AgentHub automatically**
|
||||
(`GET/PUT /api/settings/autostart`, `server/lib/autostart.js`). The switch IS
|
||||
the unit's enabled state — on writes a unit for the running server when none
|
||||
exists and enables it, off disables it; neither starts or stops the server. A
|
||||
@@ -318,6 +352,26 @@ SIGTERM from anything at all would otherwise leave the dashboard down for good.
|
||||
An explicit `systemctl stop` — which is what `agenthub service uninstall` and
|
||||
the Settings panel do — still stops it.
|
||||
|
||||
The unit also sets **`KillMode=process`**: a stop or an update's restart ends
|
||||
the server process alone. tmux forks its server from whichever process first
|
||||
asks for a session — the dashboard itself after a boot with autostart on — so
|
||||
it can live in the unit's cgroup, and `control-group` would kill it and every
|
||||
lane pane on each update. **An update never closes a pane; only Quit does**
|
||||
(it kills the `agenthub-lane-*` sessions itself). Existing units pick this up
|
||||
when the next update rewrites them; a checkout's unit on `agenthub service
|
||||
install`. The launchd twin is `AbandonProcessGroup`.
|
||||
|
||||
Belt and braces on Linux: the session that STARTS the tmux server (no server
|
||||
running yet) is created through `systemd-run --user --scope`
|
||||
(`server/lib/tmux.js`), so the server lives in a transient scope of its own —
|
||||
not in `agenthub.service`, and not in the terminal or IDE scope a dev server
|
||||
was started from. Stopping either, systemd-oomd reaping either, or an old unit
|
||||
still on `control-group` leaves the panes alone. With no user manager to ask
|
||||
(SSH without linger, no systemd) it falls back to a plain start. On macOS tmux
|
||||
daemonizes into its own session and the plist abandons its process group. On
|
||||
Windows (psmux) whether the server outlives the dashboard has not been
|
||||
verified yet.
|
||||
|
||||
`agenthub service install` (the checkout path) keeps using `installUserService`:
|
||||
it runs from your own shell, outside the unit's cgroup, so it may block and
|
||||
start in one step.
|
||||
|
||||
+11
-2
@@ -115,7 +115,8 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
session a member owns (its page, transcript, events or cost), the read is
|
||||
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
|
||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days.
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
||||
Users page in the sidebar shows it, with the audit log under it.
|
||||
|
||||
A member also cannot open the host's own configuration, or any view across all
|
||||
users: Claude Code / Codex config (except the command and skill lists the
|
||||
@@ -183,7 +184,15 @@ apart, not agents.
|
||||
|
||||
## Accounts
|
||||
|
||||
Accounts are managed from the host's shell, never from a web route:
|
||||
An admin manages accounts on the **Users** page (sidebar; admins and analysts
|
||||
see it, an analyst only reads): add a user, change a role, disable or enable,
|
||||
reset a password, give a workspace. Every change asks for the admin's OWN
|
||||
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
|
||||
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
|
||||
themselves (`ESELF`), and each change is written to the audit log. In local
|
||||
mode these routes refuse (`ELOCALMODE`): there is no login to check.
|
||||
|
||||
The same from the host's shell:
|
||||
|
||||
```bash
|
||||
agenthub users list
|
||||
|
||||
@@ -12,3 +12,35 @@ Goal (2026-10-07): finish the server part — S2 ownership as proposed, review i
|
||||
- [x] Full suites: server 2532/2535 (3 lane-detect fail on a foreign process holding port 3000, same on clean HEAD), client 1074/1074; browser e2e member vs admin; docs synced
|
||||
- [x] Upgrade-impact review (2026-10-07): old vs new API diff on a copy of the real DB (local mode identical but for noise), old+new builds sharing one migrated DB, 5-lens review + verify. Fixed: lock names (any single dir name ≤255, holders with spaces normalised, CLI fails fast on non-409), AuthGate rendering on an older server / DASHBOARD_TOKEN 401, pane_token_hash kept out of exports; documented the LAN origin change. Suites: server 2536/2536, client 1078/1078
|
||||
- Blocked on sudo / a real host: S0 spike, S3 (one Linux user per account), S4 (shared /opt tools)
|
||||
|
||||
## 2026-10-08 — per-user isolation (S3/S4 + per-user proxy/plugins + Codex pool)
|
||||
|
||||
Decided: own Linux user per account; skills/plugins = admin baseline + user's own; AI Proxy = user's own + admin's shared (no key shown); tool binaries shared under `/opt/agenthub/tools`; Claude pool shared (exists); NEW Codex multi-account pool. Spike allowed on this machine with sudo (undo log: `/tmp/agh-spike/UNDO.sh`).
|
||||
|
||||
- [x] S0 spike (2026-10-08, this machine; test users agh-test1/2 kept for S3 dev, undo `/tmp/agh-spike/UNDO.sh`) → results below
|
||||
- [ ] Per-user AI Proxy store (+ admin shared)
|
||||
- [ ] S3 provisioning + spawn/attach as the account
|
||||
- [ ] S4 shared tools in /opt
|
||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline)
|
||||
- [ ] Codex account pool
|
||||
- [ ] Security review, docs, suites
|
||||
|
||||
### S0 results (claude 2.1.293, codex 0.155.1, tmux 3.7c, node 26)
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Layout: `/srv/agenthub/users` 0751 root; home `<acct>:agenthub` 0750; member's own group private | GO — a member cannot list or read another home or the host's |
|
||||
| claude / codex / node run from root-owned `/opt/agenthub/tools/bin` with a foreign HOME | GO — nothing written outside the home; a root-owned binary cannot self-update (plus `DISABLE_AUTOUPDATER=1`) |
|
||||
| Per-account tmux server, socket in a setgid `agenthub` dir, `server-access -a <host>` + `chmod 0770` | GO — host send-keys/capture/attach work, the pane runs as the member; tmux keeps group rw across attach; another member is refused |
|
||||
| Hooks as the member: `node /opt/.../hook-handler.js` → `CLAUDE_DASHBOARD_PORT` on loopback | GO — SessionStart/UserPromptSubmit arrive with the member's transcript_path |
|
||||
| Codex rollouts readable by the dashboard via group | GO — 0644 under 0775 |
|
||||
| Claude transcripts readable by the dashboard via group | **NO** — `.jsonl` created 0600, `sessions/` 0700; a default ACL is masked to `---`. Needs a decision |
|
||||
| agy | not run (it self-updates on any run); root-owned /opt stops that |
|
||||
| Host group membership | needs a new login: a running `systemd --user` keeps its old groups → server mode wants a system unit with `SupplementaryGroups=agenthub` |
|
||||
| Shared Claude pool token across Linux users | setup-token is long-lived, no refresh rotation → safe |
|
||||
| Codex pool with ChatGPT logins | untested (no spare account): refresh tokens rotate, so a copied `auth.json` must be copied back under a lock; API-key accounts have no such risk |
|
||||
|
||||
### Open decision: how the dashboard reads every tool's files (user, 2026-10-08: "all AI tools, lightest")
|
||||
|
||||
Proposal: server mode runs as a system unit with `AmbientCapabilities=CAP_DAC_READ_SEARCH` (read any file, no write; zero per-read cost, no per-tool code) fenced by `InaccessiblePaths=` (`/etc/shadow`, `/etc/gshadow`, `/etc/sudoers*`, `/root`, `/home`) and `NoNewPrivileges`. Every WRITE in a member's folder (git-ops, file editor, worktrees, tmux spawn) runs as the member via `sudo -u <acct>` (sudoers: host may run commands as `%agenthub-members` only, never root). Git reads need `-c safe.directory=*` (dubious-ownership check). Verification command not yet run (blocked by the agent's permission classifier — user to run it).
|
||||
Code work waits until the in-flight Quit/tray/Users change-set is committed, then a new branch.
|
||||
|
||||
+398
-2
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.5.11
|
||||
version: 2.5.12
|
||||
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ĩ
|
||||
@@ -1100,6 +1100,12 @@ components:
|
||||
fast_output_per_mtok:
|
||||
type: number
|
||||
description: Fast-mode output rate (0 = none)
|
||||
long_input_per_mtok:
|
||||
type: number
|
||||
description: Input rate for a request whose prompt is above 100k tokens (0 = none); cache rates scale by long_input / input
|
||||
long_output_per_mtok:
|
||||
type: number
|
||||
description: Output rate for a request whose prompt is above 100k tokens (0 = none)
|
||||
source:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -1190,6 +1196,17 @@ components:
|
||||
type: integer
|
||||
code_execution_requests:
|
||||
type: integer
|
||||
long_input_tokens:
|
||||
type: integer
|
||||
description: Part of input_tokens spent by requests whose prompt was above 100k tokens
|
||||
long_output_tokens:
|
||||
type: integer
|
||||
long_cache_read_tokens:
|
||||
type: integer
|
||||
long_cache_write_tokens:
|
||||
type: integer
|
||||
long_cache_write_1h_tokens:
|
||||
type: integer
|
||||
cost:
|
||||
type: number
|
||||
matched_rule:
|
||||
@@ -11556,6 +11573,33 @@ paths:
|
||||
description: ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/blame:
|
||||
get:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: 'Annotate: each line''s last commit'
|
||||
description: '`git blame --porcelain` of the working-tree file, for the Changes panel''s Annotate gutter. Same lexical lane guard and secret refusal as /git/file, capped at 1 MB. A line nobody committed yet is null. An untracked file (no history) answers {available:false}. Read-only, no same-origin guard.'
|
||||
operationId: blameLaneFile
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: path
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '{available, path, commits: {<sha>: {short, author, date, summary}}, lines: [<sha>|null]}.'
|
||||
'400':
|
||||
description: EBADPATH, ENOFILE or ETOOBIG.
|
||||
'403':
|
||||
description: ESECRET.
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/raw:
|
||||
get:
|
||||
tags:
|
||||
@@ -11745,6 +11789,7 @@ paths:
|
||||
- revert
|
||||
- undo-commit
|
||||
- amend
|
||||
- ignore
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -11779,10 +11824,24 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: stage, unstage, discard; each must be a path git reports changed.
|
||||
description: 'stage, unstage, discard, ignore (untracked only, else 400 ETRACKED; appends an anchored line to the workspace''s .gitignore); each must be a path git reports changed. commit and amend: commit exactly these files'' working-tree content (`git commit --only`), an untracked one included, leaving every other change — staged or not — uncommitted; a conflicted one is 409 ECONFLICTS. amend with an empty list rewords only (a message is then required, else 400 ENOCHANGES).'
|
||||
path:
|
||||
type: string
|
||||
description: stage-hunk, discard-hunk; a path git reports changed (not untracked).
|
||||
hunks:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
hunk:
|
||||
type: string
|
||||
blocks:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
description: 'commit, amend (optional): the hunk-level commit — per tracked, non-renamed path, only these change blocks of these hunks (`@@` lines as the diff at `context` returned them). Built in a temporary index from git''s own fresh diff and committed from it; the real index entries of the committed paths are then reset to the new HEAD, the rest of a partial file left as an unstaged change. A hunk no longer in the diff is 409 EHUNKGONE, an untracked path 400 EUNTRACKED, a bad shape 400 EBADPATH.'
|
||||
hunk:
|
||||
type: string
|
||||
description: stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.
|
||||
@@ -12706,6 +12765,75 @@ paths:
|
||||
description: 'EBADORIGIN: another site asked'
|
||||
'409':
|
||||
description: 'EFOREIGN: a unit named agenthub that AgentHub did not write'
|
||||
/api/settings/tray:
|
||||
get:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Whether AgentHub's icon shows in the OS status area
|
||||
operationId: getTray
|
||||
responses:
|
||||
'200':
|
||||
description: Status-area icon state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- available
|
||||
- enabled
|
||||
- running
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
description: False in server mode, or on Linux with no graphical session
|
||||
enabled:
|
||||
type: boolean
|
||||
description: On by default
|
||||
running:
|
||||
type: boolean
|
||||
description: The icon process is heartbeating
|
||||
put:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Show or hide the status-area icon
|
||||
description: Clears or writes `<data dir>/runtime/tray-off` and starts or stops the icon process. Never touches the server. Same-origin only.
|
||||
operationId: setTray
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The new state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- available
|
||||
- enabled
|
||||
- running
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
description: False in server mode, or on Linux with no graphical session
|
||||
enabled:
|
||||
type: boolean
|
||||
description: On by default
|
||||
running:
|
||||
type: boolean
|
||||
description: The icon process is heartbeating
|
||||
'400':
|
||||
description: 'EBADVALUE: `enabled` is not a boolean'
|
||||
'403':
|
||||
description: 'EBADORIGIN: another site asked'
|
||||
/api/settings/codex-hooks:
|
||||
get:
|
||||
tags:
|
||||
@@ -13426,6 +13554,274 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: createServerUser
|
||||
summary: 'Admin: add an account. Needs the admin''s own password again (currentPassword); audited'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
- currentPassword
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
minLength: 8
|
||||
role:
|
||||
type: string
|
||||
enum:
|
||||
- admin
|
||||
- analyst
|
||||
- member
|
||||
default: member
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADUSER / EBADPASSWORD / EBADROLE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: EEXISTS
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: updateServerUser
|
||||
summary: 'Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
enum:
|
||||
- admin
|
||||
- analyst
|
||||
- member
|
||||
disabled:
|
||||
type: boolean
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER / ENOLANE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ESELF — your own role or disable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/password:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: resetServerUserPassword
|
||||
summary: 'Admin: a new password for that user; every login of theirs ends'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- password
|
||||
- currentPassword
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 8
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER / ENOLANE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ESELF — your own role or disable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/workspaces:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: assignServerUserWorkspace
|
||||
summary: 'Admin: make that user the owner of a workspace (lane)'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- laneId
|
||||
- currentPassword
|
||||
properties:
|
||||
laneId:
|
||||
type: integer
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER / ENOLANE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ESELF — your own role or disable
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/audit:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
+80
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.11",
|
||||
"version": "2.5.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.5.11",
|
||||
"version": "2.5.12",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"multer": "^2.0.0",
|
||||
"redoc": "^2.5.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"systray2": "^2.1.4",
|
||||
"tar": "^7.4.3",
|
||||
"uuid": "^11.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
@@ -1150,6 +1151,20 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -1224,6 +1239,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -1453,6 +1474,18 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -2788,6 +2821,42 @@
|
||||
"url": "https://github.com/Mermade/oas-kit?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/systray2": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/systray2/-/systray2-2.1.4.tgz",
|
||||
"integrity": "sha512-ncviZ7m2fecb2tAAx7pl4nlbnwvSyL4GIKnRUvfiJXTsHysh3chSlxVxNk7Yj8jlfF2TsBn2CjLwCfhJQkR7/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.2",
|
||||
"fs-extra": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/systray2/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/systray2/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
@@ -2907,6 +2976,15 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.11",
|
||||
"version": "2.5.12",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
@@ -79,6 +79,7 @@
|
||||
"multer": "^2.0.0",
|
||||
"redoc": "^2.5.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"systray2": "^2.1.4",
|
||||
"tar": "^7.4.3",
|
||||
"uuid": "^11.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
normalizeGeo,
|
||||
normalizeTier,
|
||||
accumulateBucket,
|
||||
longArgs,
|
||||
} = require("../server/lib/token-usage");
|
||||
|
||||
const {
|
||||
@@ -822,6 +823,11 @@ function writeSessionTokens(dbModule, sessionId, tokensByModel, opts = {}) {
|
||||
tokens.webFetch || 0,
|
||||
tokens.codeExec || 0
|
||||
);
|
||||
// The exact path also clears a stale subset; the default one only raises.
|
||||
const long = longArgs(tokens);
|
||||
const key = [sessionId, tokens.model, tokens.speed, tokens.geo, tokens.tier];
|
||||
if (opts.exact) stmts.setLongTokens.run(...(long || [0, 0, 0, 0, 0]), ...key);
|
||||
else if (long) stmts.raiseLongTokens.run(...long, ...key);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
@@ -852,6 +858,11 @@ function subagentTokenRows(tokensByModel) {
|
||||
web_search_requests: b.webSearch || 0,
|
||||
web_fetch_requests: b.webFetch || 0,
|
||||
code_execution_requests: b.codeExec || 0,
|
||||
long_input_tokens: b.longInput || 0,
|
||||
long_output_tokens: b.longOutput || 0,
|
||||
long_cache_read_tokens: b.longCacheRead || 0,
|
||||
long_cache_write_tokens: b.longCacheWrite || 0,
|
||||
long_cache_write_1h_tokens: b.longCacheWrite1h || 0,
|
||||
};
|
||||
const hasUsage =
|
||||
row.input_tokens ||
|
||||
|
||||
@@ -247,4 +247,4 @@ if (require.main === module) {
|
||||
|
||||
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
|
||||
// a previous checkout install left behind (they would double-count events).
|
||||
module.exports = { installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
|
||||
module.exports = { HOOK_TYPES, installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
|
||||
|
||||
@@ -35,6 +35,7 @@ const crypto = require("crypto");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
|
||||
const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
|
||||
const { isQuit } = require("../server/lib/quit");
|
||||
const { getServerInfoPath } = require("../server/lib/server-info");
|
||||
const { isOurEntry } = require("./install-hooks");
|
||||
|
||||
@@ -178,7 +179,8 @@ function fastPathOk(rt = runtimeDir()) {
|
||||
// worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one
|
||||
// Task spawning two subagent rows.)
|
||||
if (hasLegacyHooks()) return false;
|
||||
return serverIsLive();
|
||||
// Quit (server/lib/quit.js): nothing to start until the user starts it.
|
||||
return serverIsLive() || isQuit(rt);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- node version gate */
|
||||
@@ -740,7 +742,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
// running process must be replaced"; `unitStale` is "the unit names a cache
|
||||
// directory that is no longer this one", which is invisible until a reboot
|
||||
// and must be repaired even while a perfectly healthy server is up.
|
||||
const needsServer = movedRoot || !serverIsLive();
|
||||
// A Quit holds every start off (an explicit `--force` from
|
||||
// /agenthub-update overrides it); the unit is still repaired below.
|
||||
const quit = !force && isQuit(rt);
|
||||
if (quit) log(rt, "AgentHub was quit — not starting it (agenthub start brings it back)");
|
||||
const needsServer = !quit && (movedRoot || !serverIsLive());
|
||||
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
|
||||
if (needsServer || unitStale) {
|
||||
const unit = {
|
||||
|
||||
@@ -127,6 +127,12 @@ function unitIsActive(name) {
|
||||
return String(res.stdout || "").trim() === "active";
|
||||
}
|
||||
|
||||
/** `KillMode=process`: a stop or an update's restart ends the server alone.
|
||||
* tmux forks its server from whichever process first asks for a session —
|
||||
* the dashboard itself, after a boot with autostart on — so it can sit in
|
||||
* this unit's cgroup, and the default `control-group` would kill it and every
|
||||
* lane pane with it. Panes close only on Quit (`server/lib/quit.js`), which
|
||||
* kills them itself. The launchd twin is `AbandonProcessGroup`. */
|
||||
function unitContent({ description, workingDir, execStart, env }) {
|
||||
const envLines = Object.entries(env)
|
||||
.map(([k, v]) => `Environment=${k}=${v}`)
|
||||
@@ -142,6 +148,7 @@ WorkingDirectory=${workingDir}
|
||||
ExecStart=${execStart}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=process
|
||||
${envLines}
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -42,6 +42,13 @@ const env = {
|
||||
AGENTHUB_SETTINGS_FILE: path.join(tmuxDir, "settings.env"),
|
||||
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
|
||||
AGENTHUB_PRICING_SYNC: "0",
|
||||
// Nor run the developer's real agy (it may update itself) or send their
|
||||
// real Cursor, Grok, Kimi or OpenCode login for the quota.
|
||||
AGENTHUB_AGY_USAGE: "0",
|
||||
XDG_CONFIG_HOME: tmuxDir,
|
||||
XDG_DATA_HOME: tmuxDir,
|
||||
GROK_HOME: tmuxDir,
|
||||
KIMI_CODE_HOME: tmuxDir,
|
||||
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
||||
};
|
||||
delete env.TMUX;
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file tray.js — the AgentHub icon in the OS status area, as its own process
|
||||
* (started and kept alive by `server/lib/tray.js`; `agenthub tray` runs it in
|
||||
* the foreground). Draws through `systray2`'s helper binary, polls the
|
||||
* dashboard's /api/health, and offers Open, Quit/Start (the CLI's own
|
||||
* `agenthub stop|start`, so the icon stops and starts AgentHub exactly as the
|
||||
* terminal does) and Hide this icon (the same off flag as Settings → System).
|
||||
* One per data dir: `<data dir>/runtime/tray.json`, touched as a heartbeat.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const { spawn } = require("child_process");
|
||||
const { dashboardUrl } = require("../server/lib/dashboard-url");
|
||||
const { getDataDir } = require("../server/lib/claude-home");
|
||||
const tray = require("../server/lib/tray");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
function loadSysTray() {
|
||||
try {
|
||||
return require("systray2").default;
|
||||
} catch {
|
||||
// A plugin install keeps its dependencies in the runtime dir; the CLI
|
||||
// launcher does not put that on NODE_PATH the way the server's unit does.
|
||||
return require(path.join(getDataDir(), "runtime", "node_modules", "systray2")).default;
|
||||
}
|
||||
}
|
||||
|
||||
/** npm drops the executable bit on systray2's bundled helper binaries. */
|
||||
function chmodHelper() {
|
||||
if (process.platform === "win32") return;
|
||||
const name = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
|
||||
for (const id of ["systray2", path.join(getDataDir(), "runtime", "node_modules", "systray2")]) {
|
||||
try {
|
||||
fs.chmodSync(path.join(path.dirname(require.resolve(id)), "traybin", name), 0o755);
|
||||
return;
|
||||
} catch {
|
||||
/* try the next location */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUp(base) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`${base}/api/health`, { timeout: 2000 }, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on("timeout", () => req.destroy());
|
||||
req.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin"
|
||||
? ["open", [url]]
|
||||
: process.platform === "win32"
|
||||
? ["cmd", ["/c", "start", "", url]]
|
||||
: ["xdg-open", [url]];
|
||||
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
||||
}
|
||||
|
||||
function cli(command) {
|
||||
spawn(process.execPath, [path.join(ROOT, "bin", "agenthub.js"), command], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const other = tray.liveTray();
|
||||
if (other && other.pid !== process.pid) {
|
||||
console.log(`AgentHub's icon is already running (pid ${other.pid})`);
|
||||
return;
|
||||
}
|
||||
tray.writePid(process.pid, ROOT);
|
||||
chmodHelper();
|
||||
|
||||
const SysTray = loadSysTray();
|
||||
const item = (title, extra = {}) => ({ title, tooltip: "", enabled: true, ...extra });
|
||||
const status = item("AgentHub", { enabled: false });
|
||||
const open = item("Open AgentHub");
|
||||
// Quit closes every terminal session, so it takes a second click (the web
|
||||
// UI asks in a dialog; a tray menu has none).
|
||||
const quitYes = item("Yes, quit — closes every terminal session");
|
||||
const quit = item("Quit AgentHub", { items: [quitYes] });
|
||||
const start = item("Start AgentHub", { hidden: true });
|
||||
const hide = item("Hide this icon");
|
||||
const systray = new SysTray({
|
||||
menu: {
|
||||
icon: fs.readFileSync(path.join(ROOT, "client", "public", "favicon.ico")).toString("base64"),
|
||||
isTemplateIcon: false,
|
||||
title: "",
|
||||
tooltip: "AgentHub",
|
||||
items: [status, open, quit, start, hide],
|
||||
},
|
||||
});
|
||||
|
||||
let up = null;
|
||||
// After a Quit/Start click, "Stopping…"/"Starting…" stays until the server
|
||||
// reaches the asked-for state (or 30 s pass) — not undone by the next poll.
|
||||
let want = null;
|
||||
let waitUntil = 0;
|
||||
let base = dashboardUrl();
|
||||
// systray2 finds an item by the id it stamped on OUR object — so update in place.
|
||||
const show = (it, changes) => {
|
||||
Object.assign(it, changes);
|
||||
systray.sendAction({ type: "update-item", item: it });
|
||||
};
|
||||
const host = () => base.replace(/^https?:\/\//, "");
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
fs.utimesSync(tray.pidPath(), now, now);
|
||||
} catch {
|
||||
tray.writePid(process.pid, ROOT); // the file went missing: claim it again
|
||||
}
|
||||
base = dashboardUrl();
|
||||
const next = await isUp(base);
|
||||
if (Date.now() < waitUntil && next !== want) return;
|
||||
waitUntil = 0;
|
||||
if (next === up) return;
|
||||
up = next;
|
||||
show(status, { title: up ? `AgentHub — running (${host()})` : "AgentHub — stopped" });
|
||||
show(open, { enabled: up });
|
||||
show(quit, { hidden: !up });
|
||||
show(start, { hidden: up, enabled: true, title: "Start AgentHub" });
|
||||
};
|
||||
|
||||
const exit = () => {
|
||||
try {
|
||||
if (tray.liveTray()?.pid === process.pid) fs.rmSync(tray.pidPath(), { force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
systray.kill(true);
|
||||
setTimeout(() => process.exit(0), 2000); // a helper that never started cannot be killed
|
||||
};
|
||||
process.on("SIGTERM", exit);
|
||||
process.on("SIGINT", exit);
|
||||
|
||||
systray.onClick(({ item: clicked }) => {
|
||||
if (clicked.__id === open.__id) openUrl(base);
|
||||
else if (clicked.__id === quitYes.__id) {
|
||||
cli("stop");
|
||||
show(quit, { hidden: true });
|
||||
show(start, { hidden: false, enabled: false, title: "Stopping…" });
|
||||
[up, want, waitUntil] = [null, false, Date.now() + 30_000];
|
||||
} else if (clicked.__id === start.__id) {
|
||||
cli("start");
|
||||
show(start, { enabled: false, title: "Starting…" });
|
||||
[up, want, waitUntil] = [null, true, Date.now() + 30_000];
|
||||
} else if (clicked.__id === hide.__id) {
|
||||
tray.setEnabled(false); // writes the off flag and signals this process
|
||||
}
|
||||
});
|
||||
|
||||
systray
|
||||
.ready()
|
||||
.then(() => {
|
||||
systray.onExit(() => process.exit(0)); // the helper exists only once ready
|
||||
poll();
|
||||
setInterval(poll, tray.HEARTBEAT_MS);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`AgentHub icon could not start: ${err?.message || err}`);
|
||||
exit();
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { main };
|
||||
@@ -59,7 +59,7 @@ describe("/api/ai-tools", () => {
|
||||
assert.equal(agy.path, path.join(BIN, "agy"));
|
||||
assert.equal(agy.resume, true);
|
||||
assert.equal(agy.bypass, true);
|
||||
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
|
||||
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
|
||||
assert.equal("args" in agy, false);
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("/api/ai-tools", () => {
|
||||
|
||||
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
||||
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
||||
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
|
||||
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
||||
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
||||
@@ -112,10 +112,10 @@ describe("/api/ai-tools", () => {
|
||||
});
|
||||
|
||||
it("refresh re-detects", async () => {
|
||||
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
|
||||
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
|
||||
const r = await request("POST", "/api/ai-tools/refresh");
|
||||
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
|
||||
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
|
||||
});
|
||||
|
||||
it("/api/run/binary resolves through the catalog", async () => {
|
||||
@@ -132,9 +132,9 @@ describe("lanes on catalog tools", () => {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
};
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.lane.tool, "gemini");
|
||||
assert.equal(r.body.lane.tool, "opencode");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
|
||||
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||
@@ -212,8 +212,8 @@ describe("install / update / icons", () => {
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
assert.equal(notInstalled.status, 409);
|
||||
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
||||
// gemini was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
||||
// opencode was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
|
||||
assert.equal(notNpm.status, 409);
|
||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
|
||||
@@ -241,7 +241,7 @@ describe("install / update / icons", () => {
|
||||
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
||||
const v = await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(v.status, 200);
|
||||
assert.equal(v.body.gemini.npmManaged, false);
|
||||
assert.equal(v.body.opencode.npmManaged, false);
|
||||
});
|
||||
|
||||
it("update-all answers ENOTHING when nothing has an update", async () => {
|
||||
|
||||
@@ -82,6 +82,19 @@ describe("commit-message", () => {
|
||||
assert.doesNotMatch(input, /staged line/);
|
||||
});
|
||||
|
||||
it("with paths, reads exactly those files' working-tree changes, untracked included", async () => {
|
||||
write("a.txt", "one\nticked edit\n");
|
||||
write("new.txt", "brand new\n");
|
||||
write("other.txt", "not ticked\n");
|
||||
write(".claude/settings.local.json", '{"env":{"KEY":"sk-ticked"}}\n');
|
||||
const input = await buildInput(REPO, ["a.txt", "new.txt", ".claude/settings.local.json"]);
|
||||
assert.match(input, /ticked edit/);
|
||||
assert.match(input, /brand new/);
|
||||
assert.doesNotMatch(input, /not ticked|staged line|sk-ticked|settings\.local/);
|
||||
await assert.rejects(buildInput(REPO, ["nope.txt"]), { code: "EBADPATH" });
|
||||
await assert.rejects(buildInput(REPO, []), { code: "ENOSTAGED" });
|
||||
});
|
||||
|
||||
it("reports a failing or silent CLI as EAI", async () => {
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
|
||||
code: "EAI",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @file The Changes panel's JetBrains-style commit against a REAL repo:
|
||||
* `commit`/`amend` with `paths` take exactly the ticked files' working-tree
|
||||
* content (an untracked one included) and leave every other change — staged
|
||||
* or not — where it was; a failed commit puts a ticked untracked file back
|
||||
* out of the index. And `ignore` (Add to .gitignore): an anchored, escaped
|
||||
* line in the workspace's own .gitignore, only for an untracked path, never
|
||||
* twice, never through a symlink. Then the hunk-level commit (`hunks`: only
|
||||
* the ticked change blocks, through a temporary index, the real index synced
|
||||
* afterwards and the working tree untouched), `blameFile` (Annotate) and a
|
||||
* file's history with its parents rewritten so the graph joins up.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = 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 { runGitAction } = require("../lib/git-ops");
|
||||
const { blameFile, listCommits } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
const status = () => g("status", "--porcelain=v1", "--untracked-files=all");
|
||||
const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean);
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
write("a.txt", "a\n");
|
||||
write("b.txt", "b\n");
|
||||
write("c.txt", "c\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
});
|
||||
|
||||
describe("commit with paths", () => {
|
||||
it("commits only the ticked files, untracked included, and leaves the rest", async () => {
|
||||
write("a.txt", "a2\n"); // ticked, unstaged
|
||||
write("b.txt", "b2\n");
|
||||
g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted
|
||||
write("c.txt", "c2\n"); // not ticked
|
||||
write("new.txt", "n\n"); // ticked, untracked
|
||||
await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "pick");
|
||||
assert.deepEqual(
|
||||
g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(),
|
||||
["a.txt", "new.txt"]
|
||||
);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^M {2}b\.txt$/m);
|
||||
assert.match(status(), /^ M c\.txt$/m);
|
||||
});
|
||||
|
||||
it("commits a partly staged file's working-tree content, a deletion too", async () => {
|
||||
write("a.txt", "staged\n");
|
||||
g("add", "a.txt");
|
||||
write("a.txt", "staged\nand more\n");
|
||||
fs.rmSync(path.join(REPO, "b.txt"));
|
||||
await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] });
|
||||
assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n");
|
||||
assert.ok(!inHead().includes("b.txt"));
|
||||
assert.equal(status(), "");
|
||||
});
|
||||
|
||||
it("refuses an empty list and a path git does not report changed", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
});
|
||||
|
||||
it("puts a ticked untracked file back out of the index when the commit fails", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const hook = path.join(REPO, ".git", "hooks", "pre-commit");
|
||||
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] }));
|
||||
assert.match(status(), /^\?\? new\.txt$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("amend with paths", () => {
|
||||
it("an empty list rewords only, leaving the staged change out", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("add", "a.txt");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "reworded");
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a\n");
|
||||
assert.match(status(), /^M {2}a\.txt$/m);
|
||||
});
|
||||
|
||||
it("folds the ticked files in, and refuses an empty list with no message", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), {
|
||||
code: "ENOCHANGES",
|
||||
});
|
||||
await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "init");
|
||||
assert.ok(inHead().includes("new.txt"));
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignore", () => {
|
||||
it("appends an anchored, escaped line once, keeping the file's last line", async () => {
|
||||
write(".gitignore", "node_modules");
|
||||
write("debug*.log", "x\n");
|
||||
write("note.md", "x\n");
|
||||
await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] });
|
||||
assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n");
|
||||
assert.doesNotMatch(status(), /debug|note/);
|
||||
});
|
||||
|
||||
it("creates the file when there is none, in a subdirectory too", async () => {
|
||||
fs.mkdirSync(path.join(REPO, "dir"));
|
||||
write("dir/x y.tmp", "x\n");
|
||||
await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" });
|
||||
assert.equal(read(".gitignore"), "/dir/x y.tmp\n");
|
||||
assert.doesNotMatch(status(), /x y\.tmp/);
|
||||
});
|
||||
|
||||
it("refuses a tracked file and a .gitignore that is a symlink", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" });
|
||||
write("new.txt", "n\n");
|
||||
const outside = path.join(ROOT, "outside-ignore");
|
||||
fs.writeFileSync(outside, "");
|
||||
fs.symlinkSync(outside, path.join(REPO, ".gitignore"));
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
assert.equal(fs.readFileSync(outside, "utf8"), "");
|
||||
});
|
||||
});
|
||||
|
||||
/** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */
|
||||
const headers = (file) =>
|
||||
g("diff", "-U3", "HEAD", "--", file)
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("@@ "));
|
||||
|
||||
describe("commit with hunks", () => {
|
||||
beforeEach(() => {
|
||||
write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`);
|
||||
g("add", "long.txt");
|
||||
g("commit", "-qm", "long");
|
||||
});
|
||||
const edit = (pairs) => {
|
||||
let text = read("long.txt");
|
||||
for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to);
|
||||
write("long.txt", text);
|
||||
};
|
||||
|
||||
it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const [first] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "part",
|
||||
context: 3,
|
||||
hunks: { "long.txt": [{ hunk: first, blocks: [0] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^three$/m);
|
||||
assert.match(committed, /^25$/m);
|
||||
assert.match(read("long.txt"), /^twentyfive$/m);
|
||||
// The index followed the commit: the rest is an unstaged change only.
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("takes one block of a hunk that holds two, with whole files beside it", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["5", "five"],
|
||||
]);
|
||||
write("a.txt", "a2\n");
|
||||
const [only] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "block",
|
||||
context: 3,
|
||||
paths: ["a.txt"],
|
||||
hunks: { "long.txt": [{ hunk: only, blocks: [1] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^3$/m);
|
||||
assert.match(committed, /^five$/m);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
assert.doesNotMatch(status(), /a\.txt/);
|
||||
});
|
||||
|
||||
it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => {
|
||||
edit([["3", "three"]]);
|
||||
write("new.txt", "n\n");
|
||||
g("add", "b.txt");
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EUNTRACKED" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] },
|
||||
}),
|
||||
{ code: "EBADPATH" }
|
||||
);
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("amends with only the ticked hunk", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
const [, second] = headers("long.txt");
|
||||
await runGitAction(REPO, "amend", {
|
||||
sha: head,
|
||||
context: 3,
|
||||
paths: [],
|
||||
hunks: { "long.txt": [{ hunk: second, blocks: [0] }] },
|
||||
});
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "long");
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^twentyfive$/m);
|
||||
assert.match(committed, /^3$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blameFile", () => {
|
||||
it("names each line's commit, and none for a line not committed yet", async () => {
|
||||
write("a.txt", "a\nnew line\n");
|
||||
const blame = await blameFile(REPO, "a.txt");
|
||||
assert.equal(blame.lines.length, 2);
|
||||
const first = blame.commits[blame.lines[0]];
|
||||
assert.equal(first.summary, "init");
|
||||
assert.equal(first.author, "t");
|
||||
assert.equal(blame.lines[1], null);
|
||||
});
|
||||
|
||||
it("refuses a path outside the lane and a secret file", async () => {
|
||||
await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" });
|
||||
fs.mkdirSync(path.join(REPO, ".claude"));
|
||||
write(".claude/settings.local.json", "{}\n");
|
||||
g("add", "-f", ".claude/settings.local.json");
|
||||
g("commit", "-qm", "secret");
|
||||
await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a file's history", () => {
|
||||
it("rewrites parents to the file's own commits, so the graph joins up", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("commit", "-qam", "a2");
|
||||
write("b.txt", "b2\n");
|
||||
g("commit", "-qam", "b only");
|
||||
write("a.txt", "a3\n");
|
||||
g("commit", "-qam", "a3");
|
||||
const commits = await listCommits(REPO, { file: "a.txt" });
|
||||
assert.deepEqual(
|
||||
commits.map((c) => c.subject),
|
||||
["a3", "a2", "init"]
|
||||
);
|
||||
assert.deepEqual(commits[0].parents, [commits[1].sha]);
|
||||
});
|
||||
});
|
||||
@@ -141,3 +141,12 @@ describe("install-hooks host-only guard (#193)", () => {
|
||||
assert.equal(isInsideContainer(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOOK_TYPES", () => {
|
||||
it("is the event list the plugin ships, so the hook status checks every one", () => {
|
||||
const exported = require("../../scripts/install-hooks").HOOK_TYPES;
|
||||
const plugin = require("../../.claude-plugin/plugin.json");
|
||||
assert.deepEqual([...exported].sort(), Object.keys(plugin.hooks).sort());
|
||||
assert.deepEqual([...exported].sort(), [...HOOK_TYPES].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +146,16 @@ describe("importing a .codex-pet bundle", () => {
|
||||
assert.equal(r.status, 201);
|
||||
});
|
||||
|
||||
it("accepts a v2 sheet (1536×2288, the 16 gaze poses in rows 9–10)", async () => {
|
||||
const r = await request("POST", "/api/pets/import", {
|
||||
body: bundle({
|
||||
"pet.json": { spriteVersionNumber: 2 },
|
||||
"spritesheet.webp": webp(1536, 2288),
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
});
|
||||
|
||||
it("refuses a bundle missing a file, a sheet of the wrong size, and a non-zip", async () => {
|
||||
const missing = await request("POST", "/api/pets/import", { body: bundle({ "pet.json": {} }) });
|
||||
assert.equal(missing.status, 400);
|
||||
|
||||
@@ -205,6 +205,37 @@ describe("fast path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fast path after a Quit (issue #65)", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
it("starts nothing while the stopped flag is set, and starts again once it is gone", () => {
|
||||
const pkg = require("../../package.json");
|
||||
const { markQuit, clearQuit } = require("../lib/quit");
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = TMP_HOME; // the real unit may name another root: stale
|
||||
try {
|
||||
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
||||
boot.writeState(
|
||||
{
|
||||
pluginVersion: pkg.version,
|
||||
depsHash: boot.depsHash(),
|
||||
pluginRoot: path.resolve(__dirname, "..", ".."),
|
||||
},
|
||||
RT
|
||||
);
|
||||
// No server is live: without the flag the worker would start one.
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
markQuit(RT);
|
||||
assert.equal(boot.fastPathOk(RT), true);
|
||||
clearQuit(RT);
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
} finally {
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap lock", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Unit tests for the enhanced pricing calculator and the shared token-usage
|
||||
* normalizer: 5m/1h cache-write split, server-tool surcharges, and the per-bucket
|
||||
* pricing modifiers (fast mode, US data residency, Batch API).
|
||||
* pricing modifiers (fast mode, US data residency, Batch API), and the
|
||||
* long-context subsets priced at a rule's long_* rates.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -363,3 +364,72 @@ describe("DEFAULT_PRICING — Opus 5.5", () => {
|
||||
assert.equal(r.total_cost.toFixed(2), "2.72");
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context tier (prompt > 100k tokens)", () => {
|
||||
// Haiku 5.5: every rate x5 above a 100k-token prompt.
|
||||
const HAIKU = [
|
||||
{
|
||||
model_pattern: "claude-haiku-5-5%",
|
||||
display_name: "Claude Haiku 5.5",
|
||||
input_per_mtok: 0.1,
|
||||
output_per_mtok: 0.5,
|
||||
cache_read_per_mtok: 0.01,
|
||||
cache_write_per_mtok: 0.125,
|
||||
cache_write_1h_per_mtok: 0.2,
|
||||
long_input_per_mtok: 0.5,
|
||||
long_output_per_mtok: 2.5,
|
||||
},
|
||||
];
|
||||
const haiku = (extra) => bucket({ model: "claude-haiku-5-5", ...extra });
|
||||
|
||||
it("counts a request into the long subsets by its whole prompt: input + cache read + cache write", () => {
|
||||
const short = extractUsageFields({ input_tokens: 100_000, output_tokens: 5 });
|
||||
assert.equal(short.longInput, 0);
|
||||
const long = extractUsageFields({
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 90_000,
|
||||
cache_creation_input_tokens: 10_000,
|
||||
cache_creation: { ephemeral_5m_input_tokens: 4_000, ephemeral_1h_input_tokens: 6_000 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[
|
||||
long.longInput,
|
||||
long.longOutput,
|
||||
long.longCacheRead,
|
||||
long.longCacheWrite,
|
||||
long.longCacheWrite1h,
|
||||
],
|
||||
[10, 5, 90_000, 10_000, 6_000]
|
||||
);
|
||||
});
|
||||
|
||||
it("prices the long subset at the long rates, cache rates scaled by the input ratio", () => {
|
||||
const all = {
|
||||
input_tokens: M,
|
||||
output_tokens: M,
|
||||
cache_read_tokens: M,
|
||||
cache_write_tokens: 2 * M,
|
||||
cache_write_1h_tokens: M,
|
||||
};
|
||||
const long = Object.fromEntries(Object.entries(all).map(([k, v]) => [`long_${k}`, v]));
|
||||
// 0.5 + 2.5 + 0.05 + 0.625 + 1.0
|
||||
assert.equal(calculateCost([haiku({ ...all, ...long })], HAIKU).total_cost, 4.675);
|
||||
});
|
||||
|
||||
it("prices only the long part of a bucket at the long rates", () => {
|
||||
const r = calculateCost([haiku({ input_tokens: 2 * M, long_input_tokens: M })], HAIKU);
|
||||
assert.equal(r.total_cost, 0.6);
|
||||
assert.equal(r.breakdown[0].long_input_tokens, M);
|
||||
});
|
||||
|
||||
it("never prices more long tokens than the bucket holds", () => {
|
||||
const r = calculateCost([haiku({ input_tokens: M, long_input_tokens: 3 * M })], HAIKU);
|
||||
assert.equal(r.total_cost, 0.5);
|
||||
});
|
||||
|
||||
it("prices a long subset at the standard rates when the rule has no long rates", () => {
|
||||
const r = calculateCost([bucket({ input_tokens: M, long_input_tokens: M })], RULES);
|
||||
assert.equal(r.total_cost, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* before it is used, the sync's own rows follow a rate change while a user's
|
||||
* row and a deleted pattern are never touched, a bad list or a failed fetch
|
||||
* changes nothing, and an OpenAI model (a Codex session's) is priced from its
|
||||
* `openai` entry too.
|
||||
* `openai` entry too, and a Claude entry's above-100k rates fill the long_*
|
||||
* columns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -428,3 +429,34 @@ describe("pricing sync", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context rates", () => {
|
||||
it("takes Anthropic's above-100k input/output rates into the long columns", () => {
|
||||
const e = {
|
||||
...LIST["claude-opus-9-9"],
|
||||
input_cost_per_token_above_100k_tokens: 0.00002,
|
||||
output_cost_per_token_above_100k_tokens: 0.0001,
|
||||
};
|
||||
const r = toRow("claude-opus-9-9", e);
|
||||
assert.equal(r.longIn, 20);
|
||||
assert.equal(r.longOut, 100);
|
||||
assert.equal(toRow("claude-opus-9-9", LIST["claude-opus-9-9"]).longIn, 0);
|
||||
});
|
||||
|
||||
it("writes them, and fills them into a sync row that predates them", async () => {
|
||||
const e = {
|
||||
...LIST["claude-opus-9-9"],
|
||||
input_cost_per_token_above_100k_tokens: 0.00002,
|
||||
output_cost_per_token_above_100k_tokens: 0.0001,
|
||||
};
|
||||
useModel("claude-opus-9-9");
|
||||
await syncPricing(db, {
|
||||
fetchList: async () => ({ "claude-opus-9-9": LIST["claude-opus-9-9"] }),
|
||||
});
|
||||
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 0);
|
||||
_resetCache();
|
||||
await syncPricing(db, { fetchList: async () => ({ "claude-opus-9-9": e }) });
|
||||
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 20);
|
||||
assert.equal(row("claude-opus-9-9%").long_output_per_mtok, 100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -651,7 +651,7 @@ describe("pty-run", () => {
|
||||
let savedPath;
|
||||
before(() => {
|
||||
fs.mkdirSync(BIN, { recursive: true });
|
||||
for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) {
|
||||
for (const name of ["agy", "grok", "goose", "command-code"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -734,15 +734,11 @@ describe("pty-run", () => {
|
||||
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
|
||||
)
|
||||
);
|
||||
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
|
||||
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
|
||||
// Behind a `--` separator the prompt is already a positional, kept as typed.
|
||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
|
||||
assert.deepEqual(argvFor("gemini", { bypass: false }), [
|
||||
at("gemini"),
|
||||
"--prompt-interactive",
|
||||
" -x y",
|
||||
]);
|
||||
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
|
||||
});
|
||||
|
||||
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file Tests Quit AgentHub (issue #65, server/lib/quit.js and
|
||||
* POST /api/settings/quit): the stopped flag, stopping through the service
|
||||
* manager when a unit of ours runs the server (a SIGTERM would be restarted),
|
||||
* and the route killing every lane pane before it stops. tmux runs only in the
|
||||
* isolated server `npm run test:server` sets up.
|
||||
* @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 http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-quit-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const quit = require("../lib/quit");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("stopped flag", () => {
|
||||
it("is set by markQuit and cleared by clearQuit, in the data dir's runtime", () => {
|
||||
assert.equal(quit.flagPath(), path.join(TMP, "runtime", "stopped"));
|
||||
assert.equal(quit.isQuit(), false);
|
||||
quit.markQuit();
|
||||
assert.equal(quit.isQuit(), true);
|
||||
quit.clearQuit();
|
||||
assert.equal(quit.isQuit(), false);
|
||||
quit.clearQuit(); // already gone: fine
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopServer", () => {
|
||||
const fakeSvc = (kind, active) => ({
|
||||
kind,
|
||||
systemctlAvailable: () => true,
|
||||
unitIsOurs: () => true,
|
||||
unitIsActive: () => active,
|
||||
});
|
||||
const spawned = [];
|
||||
const spawn = (cmd, args) => {
|
||||
spawned.push([cmd, ...args].join(" "));
|
||||
return { unref() {} };
|
||||
};
|
||||
|
||||
it("SIGTERMs itself when no unit of ours is running", () => {
|
||||
let killed = 0;
|
||||
quit.stopServer({ svc: fakeSvc("systemd", false), spawn, kill: () => killed++ });
|
||||
assert.equal(killed, 1);
|
||||
assert.deepEqual(spawned, []);
|
||||
});
|
||||
|
||||
it("stops a running unit through systemd, never by a signal Restart=always undoes", () => {
|
||||
let killed = 0;
|
||||
quit.stopServer({ svc: fakeSvc("systemd", true), spawn, kill: () => killed++ });
|
||||
assert.equal(killed, 0);
|
||||
assert.deepEqual(spawned.splice(0), ["systemctl --user stop --no-block agenthub.service"]);
|
||||
});
|
||||
|
||||
it("boots a running LaunchAgent out", { skip: process.platform === "win32" }, () => {
|
||||
quit.stopServer({ svc: fakeSvc("launchd", true), spawn, kill: () => {} });
|
||||
assert.match(spawned.splice(0)[0], /^launchctl bootout gui\/\d+\/io\.smartgift\.agenthub$/);
|
||||
});
|
||||
});
|
||||
|
||||
// Only inside the runner's private tmux server: never the developer's own.
|
||||
const isolatedTmux = !!process.env.TMUX_TMPDIR && !process.env.TMUX;
|
||||
let hasTmux = false;
|
||||
try {
|
||||
execFileSync("tmux", ["-V"], { stdio: "ignore" });
|
||||
hasTmux = true;
|
||||
} catch {
|
||||
/* not installed */
|
||||
}
|
||||
|
||||
describe(
|
||||
"POST /api/settings/quit",
|
||||
{ skip: !(isolatedTmux && hasTmux) && "needs the runner's isolated tmux" },
|
||||
() => {
|
||||
let server;
|
||||
let base;
|
||||
let stops = 0;
|
||||
const realStop = quit.stopServer;
|
||||
|
||||
before(async () => {
|
||||
quit.stopServer = () => stops++; // this process is the test runner
|
||||
const { createApp, startServer } = require("../index");
|
||||
server = await startServer(createApp(), 0);
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
quit.stopServer = realStop;
|
||||
server?.close();
|
||||
require("../db").db.close();
|
||||
});
|
||||
|
||||
it("closes every lane pane, sets the flag, then stops the server", async () => {
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001", "sleep 60"]);
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001-2", "sleep 60"]);
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "not-ours", "sleep 60"]);
|
||||
try {
|
||||
const body = await new Promise((resolve, reject) => {
|
||||
const req = http.request(`${base}/api/settings/quit`, { method: "POST" }, (res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () => resolve(JSON.parse(text)));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
assert.deepEqual(body, { ok: true, killed: 2 });
|
||||
assert.equal(quit.isQuit(), true);
|
||||
const left = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.deepEqual(left.trim().split("\n"), ["not-ours"]);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(stops, 1);
|
||||
} finally {
|
||||
execFileSync("tmux", ["kill-session", "-t", "not-ours"]);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -4,7 +4,11 @@
|
||||
* 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, and never shown
|
||||
* with the usage of a token that belongs to a different account.
|
||||
* with the usage of a token that belongs to a different account; each
|
||||
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
|
||||
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
|
||||
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
|
||||
* while valid, never refreshed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +16,19 @@ const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { codexWindow, windowLabel } = require("../lib/quota");
|
||||
|
||||
// No test may read (or send) the machine's real Cursor login.
|
||||
process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-xdg-")
|
||||
);
|
||||
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
// Nor its real Grok, Kimi or OpenCode logins.
|
||||
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
|
||||
process.env[k] = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
|
||||
);
|
||||
}
|
||||
|
||||
test("windowLabel names the plan windows", () => {
|
||||
assert.strictEqual(windowLabel(300), "5h");
|
||||
assert.strictEqual(windowLabel(10080), "wk");
|
||||
@@ -47,11 +64,18 @@ test("codexAccount reads the email and access-token expiry, never a token", () =
|
||||
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 }) },
|
||||
tokens: {
|
||||
id_token: jwt({
|
||||
email: "a@b.c",
|
||||
"https://api.openai.com/auth": { chatgpt_plan_type: "plus" },
|
||||
}),
|
||||
access_token: jwt({ exp: 1_800_000_000 }),
|
||||
},
|
||||
})
|
||||
);
|
||||
assert.deepStrictEqual(codexAccount(), {
|
||||
account: "a@b.c",
|
||||
plan: "plus",
|
||||
expiresAt: new Date(1_800_000_000_000).toISOString(),
|
||||
});
|
||||
} finally {
|
||||
@@ -177,7 +201,13 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
// Thắng's identity, Victor's token.
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "live-v",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
subscriptionType: "max",
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`${home}.json`,
|
||||
@@ -200,6 +230,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
assert.strictEqual(live.label, "Victor");
|
||||
assert.strictEqual(live.account, "victor@x.y");
|
||||
assert.strictEqual(live.ownerUuid, undefined);
|
||||
assert.strictEqual(live.plan, "max");
|
||||
assert.deepStrictEqual(
|
||||
claude.filter((t) => !t.active).map((t) => t.label),
|
||||
["Thắng"]
|
||||
@@ -212,3 +243,344 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Cursor: the CLI's own login asks cursor.com for its plan + cycle usage, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-cursor-"));
|
||||
const auth = path.join(root, "config", "cursor", "auth.json");
|
||||
fs.mkdirSync(path.dirname(auth), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "cursor"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "cursor", "cli-config.json"),
|
||||
JSON.stringify({ authInfo: { email: "c@d.e", authId: "auth0|user_1" } })
|
||||
);
|
||||
const prev = {
|
||||
xdg: process.env.XDG_CONFIG_HOME,
|
||||
dir: process.env.AGENTHUB_CURSOR_DIR,
|
||||
fetch: global.fetch,
|
||||
};
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(root, "cursor");
|
||||
const asked = [];
|
||||
global.fetch = async (url, opts) => {
|
||||
if (!url.includes("cursor.com")) return { ok: false, status: 401, json: async () => ({}) };
|
||||
asked.push(`${url} ${opts.headers.Cookie}`);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
billingCycleEnd: "2099-01-01T00:00:00.000Z",
|
||||
membershipType: "pro",
|
||||
individualUsage: { plan: { totalPercentUsed: 9.5 } },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const cursor = async () => (await quotas()).tools.find((t) => t.id === "cursor");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await cursor(), undefined); // not logged in: no row
|
||||
const token = jwt({ sub: "auth0|user_1", exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: token, refreshToken: "r" }));
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await cursor(), {
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: "c@d.e",
|
||||
plan: "pro",
|
||||
expiresAt: new Date(
|
||||
JSON.parse(Buffer.from(token.split(".")[1], "base64url")).exp * 1000
|
||||
).toISOString(),
|
||||
windows: [{ label: "mo", usedPercent: 9.5, resetsAt: "2099-01-01T00:00:00.000Z" }],
|
||||
});
|
||||
assert.deepStrictEqual(asked, [
|
||||
`https://cursor.com/api/usage-summary WorkosCursorSessionToken=user_1::${token}`,
|
||||
]);
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: jwt({ sub: "auth0|user_1", exp: 1 }) }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await cursor()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(asked.length, 1);
|
||||
} finally {
|
||||
for (const [k, v] of [
|
||||
["XDG_CONFIG_HOME", prev.xdg],
|
||||
["AGENTHUB_CURSOR_DIR", prev.dir],
|
||||
]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Antigravity: `agy --print /usage` in an empty dir, one row per pool window, never on an old agy", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "quota-agy-"));
|
||||
const log = path.join(bin, "calls");
|
||||
const version = path.join(bin, "version");
|
||||
fs.writeFileSync(version, "1.2.17");
|
||||
const bucket = (id, window, rf) => ({
|
||||
id,
|
||||
window,
|
||||
remaining_fraction: rf,
|
||||
reset_time: "2099-01-01T00:00:00Z",
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "out.json"),
|
||||
JSON.stringify({
|
||||
status: "SUCCESS",
|
||||
num_turns: 0,
|
||||
command: {
|
||||
name: "usage",
|
||||
data: {
|
||||
groups: [
|
||||
{
|
||||
name: "Gemini Models",
|
||||
buckets: [bucket("g-w", "weekly", 0.75), bucket("g-5", "5h", 1)],
|
||||
},
|
||||
{ name: "Claude and GPT models", buckets: [bucket("c-5", "5h", 0.5)] },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "agy"),
|
||||
`#!/bin/sh\necho "$PWD $*" >> '${log}'\n[ "$1" = --version ] && { cat '${version}'; exit 0; }\ncat '${path.join(bin, "out.json")}'\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
const prevPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}:${prevPath}`;
|
||||
delete process.env.AGENTHUB_AGY_USAGE;
|
||||
const agy = async () => (await quotas()).tools.find((t) => t.id === "antigravity");
|
||||
try {
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
const row = await agy();
|
||||
assert.strictEqual(row.active, true);
|
||||
assert.deepStrictEqual(row.windows, [
|
||||
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: "2099-01-01T00:00:00.000Z" },
|
||||
{
|
||||
group: "Gemini Models",
|
||||
label: "wk",
|
||||
usedPercent: 25,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
group: "Claude and GPT models",
|
||||
label: "5h",
|
||||
usedPercent: 50,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const calls = fs.readFileSync(log, "utf8").trim().split("\n");
|
||||
assert.strictEqual(calls[0].split(" ").slice(1).join(" "), "--version");
|
||||
const [cwd, ...args] = calls[1].split(" ");
|
||||
assert.deepStrictEqual(args, ["--print", "/usage", "--output-format", "json"]);
|
||||
assert.notStrictEqual(cwd, process.cwd());
|
||||
assert.strictEqual(fs.existsSync(cwd), false); // the private dir is gone again
|
||||
// An agy older than 1.1.11 may bill /usage as a model request: never asked.
|
||||
fs.writeFileSync(version, "1.1.10");
|
||||
fs.writeFileSync(log, "");
|
||||
__resetCache();
|
||||
assert.strictEqual(await agy(), undefined);
|
||||
assert.match(fs.readFileSync(log, "utf8"), /^\S+ --version\n$/);
|
||||
} finally {
|
||||
process.env.PATH = prevPath;
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/** A fetch stub answering per host, recording every `url auth` it was asked. */
|
||||
function stubFetch(answers) {
|
||||
const asked = [];
|
||||
const prev = global.fetch;
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
asked.push(`${url} ${opts.headers?.Authorization || ""}`.trim());
|
||||
const hit = Object.entries(answers).find(([k]) => url.startsWith(k));
|
||||
if (!hit) return { ok: false, status: 404, json: async () => ({}) };
|
||||
const [status, body] = hit[1];
|
||||
return { ok: status < 300, status, json: async () => body };
|
||||
};
|
||||
return { asked, restore: () => (global.fetch = prev) };
|
||||
}
|
||||
|
||||
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const auth = path.join(process.env.GROK_HOME, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
|
||||
200,
|
||||
{
|
||||
config: {
|
||||
creditUsagePercent: 12,
|
||||
currentPeriod: { end: "2099-01-08T00:00:00Z" },
|
||||
subscriptionTier: "SuperGrok",
|
||||
monthlyLimit: { val: "200" },
|
||||
used: { val: "50" },
|
||||
billingPeriodEnd: "2099-02-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await grok(), undefined);
|
||||
const later = new Date(Date.now() + 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({
|
||||
"https://other.example": { key: "tok-other", expires_at: later },
|
||||
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await grok(), {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: "g@x.ai",
|
||||
plan: "SuperGrok",
|
||||
expiresAt: later,
|
||||
windows: [
|
||||
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
|
||||
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("grok.com")),
|
||||
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
|
||||
);
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
|
||||
);
|
||||
__resetCache();
|
||||
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(auth, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "kimi-code.json");
|
||||
const f = stubFetch({
|
||||
"https://api.kimi.com/coding/v1/usages": [
|
||||
200,
|
||||
{
|
||||
usages: {
|
||||
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
|
||||
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
|
||||
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
|
||||
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await kimi(), undefined);
|
||||
const exp = Math.floor(Date.now() / 1000) + 600;
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
|
||||
);
|
||||
__resetCache();
|
||||
const row = await kimi();
|
||||
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
|
||||
assert.deepStrictEqual(
|
||||
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
|
||||
[
|
||||
["5h", 30],
|
||||
["wk", 20],
|
||||
["mo", 40],
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("kimi")),
|
||||
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
|
||||
);
|
||||
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://opencode.ai/zen/go/v1/usage": [
|
||||
200,
|
||||
{
|
||||
usage: {
|
||||
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
|
||||
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
|
||||
try {
|
||||
__resetCache();
|
||||
// A login with no Go key (Copilot through opencode, say) is no row.
|
||||
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
|
||||
assert.strictEqual(await go(), undefined);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
"opencode-go": { type: "api", key: "sk-go" },
|
||||
other: { type: "api", key: "x" },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await go(), {
|
||||
id: "opencode",
|
||||
active: true,
|
||||
plan: "Go",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
|
||||
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("opencode")),
|
||||
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @file server-mode-user-admin.test.js
|
||||
* @description Managing accounts from the dashboard (Users page): only an
|
||||
* admin may create a user, change a role, disable/enable, reset a password or
|
||||
* give a workspace, every change needs the admin's OWN password again
|
||||
* (EREAUTH, throttled), an admin can never demote or disable themselves, no
|
||||
* answer carries a password hash, each change lands in the audit log, and in
|
||||
* local mode — no login at all — the routes refuse.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-admin-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
|
||||
async function call(method, p, { cookie, body, origin } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
|
||||
ids.lane = db
|
||||
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
|
||||
.run().lastInsertRowid;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("managing accounts from the dashboard", () => {
|
||||
it("lets an admin create a user, with their own password again", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: {
|
||||
username: "neo",
|
||||
password: "neo-pass-123",
|
||||
role: "member",
|
||||
currentPassword: BOSS_PASS,
|
||||
},
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
assert.deepEqual(
|
||||
{ username: r.body.user.username, role: r.body.user.role },
|
||||
{ username: "neo", role: "member" }
|
||||
);
|
||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
||||
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
|
||||
|
||||
const dup = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "neo", password: "neo-pass-123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(dup.status, 409);
|
||||
assert.equal(dup.body.error.code, "EEXISTS");
|
||||
const short = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(short.status, 400);
|
||||
assert.equal(short.body.error.code, "EBADPASSWORD");
|
||||
});
|
||||
|
||||
it("refuses without the admin's password, or with a wrong one", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
for (const currentPassword of [undefined, "not-the-password"]) {
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EREAUTH");
|
||||
}
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("is an admin's only: an analyst is read-only, a member never reaches it", async () => {
|
||||
const body = { username: "eve", password: "eve-pass-123", currentPassword: "analyst-pass-1" };
|
||||
const ana = await call("POST", "/api/users", {
|
||||
cookie: await login("ana", "analyst-pass-1"),
|
||||
body,
|
||||
});
|
||||
assert.equal(ana.status, 403);
|
||||
const mia = await call("POST", "/api/users", {
|
||||
cookie: await login("mia", "member-pass-1"),
|
||||
body: { ...body, currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(mia.status, 403);
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("refuses a write from another origin", async () => {
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: await login("boss", BOSS_PASS),
|
||||
origin: "http://evil.example",
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("changes a role, disables and enables, and resets a password", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const neo = auth.findUser("neo");
|
||||
const role = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { role: "analyst", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(role.status, 200);
|
||||
assert.equal(auth.findUser("neo").role, "analyst");
|
||||
|
||||
const neoLogin = await login("neo", "neo-pass-123");
|
||||
const off = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { disabled: true, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(off.status, 200);
|
||||
assert.equal(off.body.user.disabled, true);
|
||||
assert.equal((await call("GET", "/api/sessions", { cookie: neoLogin })).status, 401);
|
||||
assert.equal(await login("neo", "neo-pass-123"), null, "a disabled user cannot log in");
|
||||
await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { disabled: false, currentPassword: BOSS_PASS },
|
||||
});
|
||||
|
||||
const pw = await call("POST", `/api/users/${neo.id}/password`, {
|
||||
cookie: boss,
|
||||
body: { password: "neo-new-pass-9", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(pw.status, 200);
|
||||
assert.equal(await login("neo", "neo-pass-123"), null);
|
||||
assert.ok(await login("neo", "neo-new-pass-9"));
|
||||
|
||||
const bad = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { role: "root", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
const none = await call("PATCH", "/api/users/9999", {
|
||||
cookie: boss,
|
||||
body: { role: "member", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(none.status, 404);
|
||||
});
|
||||
|
||||
it("never lets an admin demote or disable themselves", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
for (const body of [{ role: "member" }, { disabled: true }]) {
|
||||
const r = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
body: { ...body, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ESELF");
|
||||
}
|
||||
assert.equal(auth.findUser("boss").role, "admin");
|
||||
});
|
||||
|
||||
it("gives a workspace to a user", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
||||
cookie: boss,
|
||||
body: { laneId: ids.lane, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(
|
||||
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.lane).owner_id,
|
||||
ids.mia
|
||||
);
|
||||
const none = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
||||
cookie: boss,
|
||||
body: { laneId: 9999, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(none.status, 404);
|
||||
});
|
||||
|
||||
it("writes every change to the audit log", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const entries = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
|
||||
const mine = entries.filter((e) => e.reader === "boss" && e.method !== "GET");
|
||||
const paths = mine.map((e) => e.path).join("\n");
|
||||
assert.match(paths, /create neo/);
|
||||
assert.match(paths, /role analyst/);
|
||||
assert.match(paths, /disable/);
|
||||
assert.match(paths, /password reset/);
|
||||
assert.match(paths, new RegExp(`workspace ${ids.lane}`));
|
||||
assert.ok(!paths.includes("neo-new-pass-9"), "never the password itself");
|
||||
});
|
||||
|
||||
it("throttles wrong confirmation passwords", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
let last;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
last = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { role: "analyst", currentPassword: "guess-" + i },
|
||||
});
|
||||
}
|
||||
assert.equal(last.status, 429);
|
||||
assert.equal(last.body.error.code, "ETOOMANY");
|
||||
assert.equal(auth.findUser("mia").role, "member");
|
||||
});
|
||||
});
|
||||
|
||||
describe("in local mode", () => {
|
||||
it("refuses every account change — there is no login to check", async () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
const r = await call("POST", "/api/users", {
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword: "x" },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "ELOCALMODE");
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,15 @@ describe("systemd-service — unit content", linuxOnly, () => {
|
||||
assert.doesNotMatch(body, /^Restart=on-failure$/m);
|
||||
});
|
||||
|
||||
it("stops only the server process, so an update never takes the lane panes with it", () => {
|
||||
// tmux forks its server from whichever process first asks for a session —
|
||||
// the dashboard, after a boot with autostart on — and that server sits in
|
||||
// this unit's cgroup. The default KillMode=control-group would kill it,
|
||||
// and every running agent, on each update's restart.
|
||||
svc.writeUserService(UNIT);
|
||||
assert.match(unitFile(), /^KillMode=process$/m);
|
||||
});
|
||||
|
||||
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
assert.match(unitFile(), /agenthub-managed-unit/);
|
||||
|
||||
@@ -57,6 +57,53 @@ describe("tmux wrapper", () => {
|
||||
assert.equal(tmux.liveSessions(), null);
|
||||
});
|
||||
|
||||
describe(
|
||||
"the tmux server outlives the dashboard (Linux)",
|
||||
{ skip: process.platform !== "linux" },
|
||||
() => {
|
||||
// Fake tmux: no server until a new-session, then every call succeeds.
|
||||
const fake =
|
||||
(calls, { running = false } = {}) =>
|
||||
(args) => {
|
||||
calls.push(["tmux", ...args].join(" "));
|
||||
if (args[0] === "display-message" && !running)
|
||||
throw Object.assign(new Error("no server"), { status: 1 });
|
||||
if (args[0] === "has-session" && !running)
|
||||
throw Object.assign(new Error("no session"), { status: 1 });
|
||||
return args[0] === "display-message" ? "4242\n" : "";
|
||||
};
|
||||
|
||||
it("starts the FIRST session in its own systemd scope, not the dashboard's cgroup", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls));
|
||||
tmux.__setScopeImpl((args) => calls.push(["systemd-run", "tmux", ...args].join(" ")));
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(
|
||||
calls.includes("systemd-run tmux new-session -d -s agenthub-lane-1 -c /w -- claude")
|
||||
);
|
||||
assert.ok(!calls.some((c) => c.startsWith("tmux new-session")));
|
||||
});
|
||||
|
||||
it("adds a session to a running server directly", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls, { running: true }));
|
||||
tmux.__setScopeImpl(() => assert.fail("a running server needs no scope"));
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
|
||||
});
|
||||
|
||||
it("starts it plainly when no systemd user manager answers", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls));
|
||||
tmux.__setScopeImpl(() => {
|
||||
throw Object.assign(new Error("Failed to connect to bus"), { status: 1 });
|
||||
});
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "new-session") return "";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* @file Tests for replaceTokenUsage's compaction baseline semantics.
|
||||
* @file Tests for replaceTokenUsage's compaction baseline semantics, and for
|
||||
* the long-context subsets written beside it (high-water mark, exact reset).
|
||||
*
|
||||
* The effective total for a bucket is `live + baseline`. It must behave as a
|
||||
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
|
||||
@@ -121,3 +122,38 @@ describe("replaceTokenUsage — high-water-mark baseline", () => {
|
||||
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context subsets", () => {
|
||||
const { writeSessionTokens } = require("../../scripts/import-history");
|
||||
const { emptyBucket, accumulateBucket, extractUsageFields } = require("../lib/token-usage");
|
||||
const longRow = (sid) =>
|
||||
db
|
||||
.prepare("SELECT input_tokens, long_input_tokens FROM token_usage WHERE session_id = ?")
|
||||
.get(sid);
|
||||
const bucketOf = (...usages) => {
|
||||
const b = emptyBucket("claude-haiku-5-5", "standard", "global", "standard");
|
||||
for (const u of usages) accumulateBucket(b, extractUsageFields(u));
|
||||
return { k: b };
|
||||
};
|
||||
|
||||
before(() => {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status) VALUES ('long-1', 'x', 'active')"
|
||||
).run();
|
||||
});
|
||||
|
||||
it("stores the long part as a subset of the same row and keeps its high-water mark", () => {
|
||||
const short = { input_tokens: 1_000 };
|
||||
const long = { input_tokens: 150_000 };
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf(short, long));
|
||||
assert.deepEqual(longRow("long-1"), { input_tokens: 151_000, long_input_tokens: 150_000 });
|
||||
// A smaller-scope writer never takes the subset back.
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf(short));
|
||||
assert.equal(longRow("long-1").long_input_tokens, 150_000);
|
||||
});
|
||||
|
||||
it("lets the exact reconcile path clear a stale subset", () => {
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf({ input_tokens: 1_000 }), { exact: true });
|
||||
assert.deepEqual(longRow("long-1"), { input_tokens: 1_000, long_input_tokens: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Tests the status-area icon's keeper (server/lib/tray.js): on by
|
||||
* default, never started once the user hid it, one icon at a time, a stale
|
||||
* heartbeat never trusted (its pid may be another program's by now), an icon
|
||||
* from another install root replaced only at server start, and no retry loop
|
||||
* for an icon that keeps dying. No real icon is spawned.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, 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 { EventEmitter } = require("node:events");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tray-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DISPLAY = process.env.DISPLAY || ":99"; // guiEnv() on Linux
|
||||
|
||||
const tray = require("../lib/tray");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
function fakeSpawn() {
|
||||
const calls = [];
|
||||
const spawn = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
const child = new EventEmitter();
|
||||
child.unref = () => {};
|
||||
return child;
|
||||
};
|
||||
return { spawn, calls };
|
||||
}
|
||||
|
||||
describe("tray keeper", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(TMP, "runtime"), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is on by default and starts scripts/tray.js detached", () => {
|
||||
assert.equal(tray.isEnabled(), true);
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "started");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].args[0], /scripts[\\/]tray\.js$/);
|
||||
assert.equal(calls[0].opts.detached, true);
|
||||
});
|
||||
|
||||
it("starts nothing once the user hid it", () => {
|
||||
fs.mkdirSync(path.dirname(tray.offPath()), { recursive: true });
|
||||
fs.writeFileSync(tray.offPath(), "x");
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "skipped");
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("leaves a live icon alone, and replaces one from another root only when asked", () => {
|
||||
tray.writePid(process.pid, "/some/other/root");
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "running");
|
||||
assert.equal(calls.length, 0);
|
||||
tray.writePid(process.pid); // this root: nothing to replace
|
||||
assert.equal(tray.ensureTray({ spawn, replace: true }), "running");
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("does not trust a heartbeat that went stale", () => {
|
||||
tray.writePid(process.pid);
|
||||
const old = new Date(Date.now() - 10 * tray.HEARTBEAT_MS);
|
||||
fs.utimesSync(tray.pidPath(), old, old);
|
||||
assert.equal(tray.liveTray(), null);
|
||||
});
|
||||
|
||||
it("writes and clears the off flag", () => {
|
||||
assert.equal(tray.setEnabled(false).enabled, false);
|
||||
assert.equal(fs.existsSync(tray.offPath()), true);
|
||||
tray.writePid(999_999_999); // no such process: nothing to signal, nothing to trust
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.setEnabled(true, { spawn }).enabled, true);
|
||||
assert.equal(fs.existsSync(tray.offPath()), false);
|
||||
assert.equal(calls.length, 1, "turning it on shows the icon at once");
|
||||
});
|
||||
|
||||
it("gives up on an icon that keeps dying at once", () => {
|
||||
const spawned = [];
|
||||
const spawn = () => {
|
||||
const child = new EventEmitter();
|
||||
child.unref = () => {};
|
||||
spawned.push(child);
|
||||
return child;
|
||||
};
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (tray.ensureTray({ spawn }) === "started") spawned.at(-1).emit("exit", 1);
|
||||
}
|
||||
assert.ok(spawned.length <= 4, `retried ${spawned.length} times`);
|
||||
assert.equal(tray.ensureTray({ spawn }), "skipped");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"id": "gemini",
|
||||
"label": "Gemini",
|
||||
"detect": [
|
||||
"gemini"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "flag",
|
||||
"flag": "--prompt-interactive"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model",
|
||||
"vendor": "google"
|
||||
},
|
||||
"bypass": [
|
||||
"--yolo"
|
||||
],
|
||||
"homepageUrl": "https://github.com/google-gemini/gemini-cli",
|
||||
"faviconDomain": "gemini.google.com",
|
||||
"install": {
|
||||
"kind": "npm",
|
||||
"package": "@google/gemini-cli"
|
||||
},
|
||||
"update": {
|
||||
"kind": "npm"
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.gemini/settings.json",
|
||||
"format": "jsonc"
|
||||
},
|
||||
{
|
||||
"path": "~/.gemini/GEMINI.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user