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:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+67 -39
View File
@@ -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`,
}}
/>
);
+30 -1
View File
@@ -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
+8 -1
View File
@@ -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
+25 -3
View File
@@ -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 () =>
+186 -17
View File
@@ -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
+8 -6
View File
@@ -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(() =>
+23 -1
View File
@@ -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) => (
+10 -2
View File
@@ -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();
});
});