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:
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
|
||||
* one per animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
|
||||
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
|
||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||||
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
||||
* own pace and holds its last frame longer, so a loop breathes instead of
|
||||
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,9 +21,29 @@ import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const SHEET = { cols: 8, rows: 9 };
|
||||
/** Frames in each row of the Codex atlas. */
|
||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||||
const COLS = 8;
|
||||
/** `n` frames of `step` ms, the last one `last` ms. */
|
||||
const frames = (n: number, step: number, last: number) =>
|
||||
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
|
||||
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
|
||||
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
|
||||
* how a sleeping pet breathes here. */
|
||||
const DURATIONS = [
|
||||
[280, 110, 110, 140, 140, 320], // idle
|
||||
frames(8, 120, 220), // run right
|
||||
frames(8, 120, 220), // run left
|
||||
frames(4, 140, 280), // waving
|
||||
frames(5, 140, 280), // jumping
|
||||
frames(8, 140, 240), // failed
|
||||
frames(6, 150, 260), // waiting
|
||||
frames(6, 120, 220), // running (working)
|
||||
frames(6, 150, 280), // review
|
||||
];
|
||||
const SLEEP_SLOWDOWN = 6;
|
||||
/** Rows that play three times and settle into idle; the others loop. */
|
||||
const MOMENTS = new Set([3, 4, 5, 8]);
|
||||
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
|
||||
const GAZE_ROW = 9;
|
||||
/** Which row plays for each mood. */
|
||||
const ROW: Record<Mood, number> = {
|
||||
idle: 0,
|
||||
@@ -36,25 +58,13 @@ const ROW: Record<Mood, number> = {
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
||||
const PACE: [number, number][] = [
|
||||
[220, 1400], // idle: a breath, then a pause
|
||||
[120, 120], // run right — keeps up with the drag
|
||||
[120, 120], // run left
|
||||
[180, 400], // waving
|
||||
[160, 700], // jumping
|
||||
[200, 1200], // failed
|
||||
[220, 900], // waiting
|
||||
[170, 400], // running (working)
|
||||
[220, 900], // review
|
||||
];
|
||||
const SLEEP_PACE: [number, number] = [450, 2000];
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
@@ -63,6 +73,8 @@ export function PetSprite({
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
@@ -70,24 +82,30 @@ export function PetSprite({
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const [frame, setFrame] = useState(0);
|
||||
// The cell on show, tagged with the row it was played for: a cell left over
|
||||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||
const [rows, setRows] = useState(9);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
setCell({ of: row, row, col: 0 });
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const count = FRAMES[row] ?? 1;
|
||||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||||
let f = 0;
|
||||
const play = (r: number, slow = 1) =>
|
||||
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
f = (f + 1) % count;
|
||||
setFrame(f);
|
||||
tick();
|
||||
},
|
||||
f === count - 1 ? hold : step
|
||||
);
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
}, list[i]!.ms);
|
||||
};
|
||||
tick();
|
||||
return () => window.clearTimeout(timer);
|
||||
@@ -96,10 +114,13 @@ export function PetSprite({
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
// It also tells a v2 sheet (11 rows) from a v1 (9).
|
||||
const probe = new Image();
|
||||
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onload = null;
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -120,11 +141,18 @@ export function PetSprite({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
|
||||
const shown =
|
||||
pose != null
|
||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||
: cell.of === row
|
||||
? cell
|
||||
: { row, col: 0 };
|
||||
const scale = size / CELL.h;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={row}
|
||||
data-row={shown.row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
@@ -132,8 +160,8 @@ export function PetSprite({
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||||
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
@@ -244,6 +245,32 @@ function SagiBody() {
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
|
||||
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
|
||||
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
|
||||
const btn = useRef<HTMLButtonElement>(null);
|
||||
const [gaze, setGaze] = useState<number | null>(null);
|
||||
const wearsSheet = "kind" in pet && pet.kind === "codex";
|
||||
useEffect(() => {
|
||||
if (!wearsSheet || reducedMotion) return;
|
||||
let rest = 0;
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const r = btn.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const deg =
|
||||
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
|
||||
Math.PI;
|
||||
setGaze(Math.round((deg + 360) / 22.5) % 16);
|
||||
window.clearTimeout(rest);
|
||||
rest = window.setTimeout(() => setGaze(null), 2000);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.clearTimeout(rest);
|
||||
};
|
||||
}, [wearsSheet, reducedMotion]);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
navigate(route);
|
||||
@@ -334,6 +361,7 @@ function SagiBody() {
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={btn}
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
@@ -368,6 +396,7 @@ function SagiBody() {
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
gaze={gaze}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
|
||||
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
|
||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
||||
act(() => vi.advanceTimersByTime(50));
|
||||
act(() => vi.advanceTimersByTime(110));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("holds a loop's last frame before starting over", () => {
|
||||
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
|
||||
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => vi.advanceTimersByTime(310));
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
|
||||
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
|
||||
act(() => vi.advanceTimersByTime(3 * 840 - 1));
|
||||
expect(sprite.dataset.row).toBe("4");
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(sprite.dataset.row).toBe("0");
|
||||
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("looks toward the pointer on a v2 sheet, only while idle", () => {
|
||||
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
naturalHeight = 2288;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// Pose 12 (270°, left) = row 10, column 4.
|
||||
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
|
||||
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
|
||||
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
gaze={4}
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -464,7 +465,13 @@ export function Sidebar({
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||
{[
|
||||
...NAV_KEYS,
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
...(auth.state.mode === "server" && auth.state.user && auth.state.user.role !== "member"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
return (
|
||||
<NavLink
|
||||
|
||||
@@ -35,7 +35,15 @@ import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
const QUOTA_POLL_MS = 5 * 60_000;
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -123,6 +131,7 @@ const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
@@ -133,6 +142,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||
{q.plan && (
|
||||
<span
|
||||
data-testid="status-bar-quota-plan"
|
||||
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
|
||||
>
|
||||
{q.plan}
|
||||
</span>
|
||||
)}
|
||||
{many && q.active !== false && (
|
||||
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||
{t("statusBar.quotaActive")}
|
||||
@@ -146,11 +163,16 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w) => {
|
||||
{q.windows.map((w, i) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={w.label}>
|
||||
<Fragment key={`${w.group ?? ""}-${w.label}`}>
|
||||
{w.group && w.group !== q.windows[i - 1]?.group && (
|
||||
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
|
||||
{w.group}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fg-muted">
|
||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||
</span>
|
||||
|
||||
@@ -24,6 +24,7 @@ function today() {
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
sessions: {
|
||||
// 3 active sessions, 1 of them waiting on the user.
|
||||
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
|
||||
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
|
||||
active: true,
|
||||
label: "Work",
|
||||
account: "me@example.com",
|
||||
plan: "max",
|
||||
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||
windows: [
|
||||
{
|
||||
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
|
||||
windows: [],
|
||||
error: "EUPSTREAM",
|
||||
},
|
||||
{
|
||||
id: "antigravity",
|
||||
active: true,
|
||||
windows: [
|
||||
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
|
||||
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
|
||||
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: "c@example.com",
|
||||
plan: "pro",
|
||||
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
|
||||
},
|
||||
],
|
||||
checkedAt: "",
|
||||
}),
|
||||
@@ -214,8 +232,8 @@ describe("StatusBar", () => {
|
||||
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
||||
await user.hover(chips);
|
||||
const panel = screen.getByTestId("status-bar-quota-panel");
|
||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workin use");
|
||||
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workmaxin use");
|
||||
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
expect(panel).toHaveTextContent("Could not read the quota");
|
||||
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
|
||||
expect(chips).toHaveTextContent("Antigravity50% used");
|
||||
expect(cursor).toHaveTextContent("c@example.compro");
|
||||
expect(cursor).toHaveTextContent("Month40%");
|
||||
expect(chips).toHaveTextContent("Cursor40% used");
|
||||
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
||||
await user.unhover(chips);
|
||||
await user.hover(panel);
|
||||
|
||||
@@ -307,6 +307,23 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists live sessions above ended ones, even an ended one active more recently", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [session("s-old", "Old one"), session("s-live", "Live one")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-session-s-live")).toHaveAttribute("data-live", "true")
|
||||
);
|
||||
const ids = screen.getAllByTestId(/^workspace-session-s-/).map((el) => el.dataset.testid);
|
||||
expect(ids).toEqual(["workspace-session-s-live", "workspace-session-s-old"]);
|
||||
});
|
||||
|
||||
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
|
||||
vi.mocked(api.run.list).mockImplementation(
|
||||
async () =>
|
||||
|
||||
@@ -13,34 +13,54 @@
|
||||
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||
* Given `onBlock`, each changed block also gets the read-only diff's Undo /
|
||||
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
|
||||
* panel turns it off while the editor holds unsaved text, because git acts on
|
||||
* the file on disk.
|
||||
* Given `onBlock`, each changed block also gets the read-only diff's bar under
|
||||
* its last line — Undo (asks twice) and the block's commit checkbox
|
||||
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
|
||||
* the editor holds unsaved text, because git acts on the file on disk.
|
||||
* Given `blame`, a gutter left of the line numbers names each line's last
|
||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||
* a function that moves the cursor to the next / previous changed block.
|
||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||
* Chat chip above it, which hands the panel the selection's 1-based lines.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
|
||||
import {
|
||||
Compartment,
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
RangeSetBuilder,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Text,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
gutter,
|
||||
GutterMarker,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
showTooltip,
|
||||
type DecorationSet,
|
||||
type Tooltip,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { chunksOf, splitDiff } from "./splitDiff";
|
||||
import { highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneBlame } from "../../lib/types";
|
||||
|
||||
type Blame = Extract<LaneBlame, { available: true }>;
|
||||
|
||||
/**
|
||||
* True when a letter or digit of a line (`text`, starting at document offset
|
||||
@@ -76,7 +96,7 @@ function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const doc = view.state.doc;
|
||||
let last = 0;
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (let pos = from; pos <= to; ) {
|
||||
for (let pos = from; pos <= to;) {
|
||||
const line = doc.lineAt(pos);
|
||||
pos = line.to + 1;
|
||||
if (line.number <= last) continue;
|
||||
@@ -151,7 +171,52 @@ const diffColours = (lang: string) =>
|
||||
/** A changed block by line (1-based; a count of 0 is a pure insertion or
|
||||
* removal): what the panel matches against git's own change blocks. */
|
||||
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
|
||||
export type BlockAction = "keep" | "undo";
|
||||
export type BlockAction = "include" | "undo";
|
||||
|
||||
/** One Annotate cell: the line's commit date and author, its subject on hover. */
|
||||
class BlameMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly text: string,
|
||||
readonly title: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
override eq(other: BlameMarker) {
|
||||
return other.text === this.text && other.title === this.title;
|
||||
}
|
||||
override toDOM() {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = this.text;
|
||||
el.title = this.title;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
|
||||
* lines are matched by number, so unsaved edits above a line shift its
|
||||
* annotation until the file is saved and re-annotated. */
|
||||
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
|
||||
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
|
||||
return gutter({
|
||||
class: "cm-ah-blame",
|
||||
lineMarker(view, line) {
|
||||
const sha = shaAt(view.state, line.from);
|
||||
if (sha === undefined) return null;
|
||||
const c = sha ? blame.commits[sha] : undefined;
|
||||
return c
|
||||
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
|
||||
: new BlameMarker(notCommitted, notCommitted);
|
||||
},
|
||||
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
|
||||
domEventHandlers: {
|
||||
mousedown(view, line) {
|
||||
const sha = shaAt(view.state, line.from);
|
||||
if (sha) onClick(sha);
|
||||
return !!sha;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A chunk side's first line and line count. `to` is the start of the line
|
||||
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
|
||||
@@ -220,6 +285,26 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Add to Chat above a non-empty selection; `build` makes its DOM from the
|
||||
* selection's first and last 1-based line. A selection ending at a line's
|
||||
* start (a whole-line drag) does not take that line. */
|
||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||
return StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(tip, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return tip;
|
||||
const { from, to, empty } = tr.state.selection.main;
|
||||
if (empty) return null;
|
||||
const doc = tr.state.doc;
|
||||
const start = doc.lineAt(from).number;
|
||||
const last = doc.lineAt(to);
|
||||
const end = to === last.from && last.number > start ? last.number - 1 : last.number;
|
||||
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) };
|
||||
},
|
||||
provide: (f) => showTooltip.from(f),
|
||||
});
|
||||
}
|
||||
|
||||
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||
const UNDO =
|
||||
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||
@@ -234,6 +319,8 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
||||
const M = "&.cm-merge-b.cm-merge-b";
|
||||
const A = "&.cm-merge-a.cm-merge-a";
|
||||
const theme = EditorView.theme({
|
||||
// The chip draws its own box; drop its wrapper's default tooltip one.
|
||||
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "11px",
|
||||
@@ -278,6 +365,13 @@ const theme = EditorView.theme({
|
||||
background: "rgb(var(--surface-2))",
|
||||
},
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
".cm-ah-blame .cm-gutterElement": {
|
||||
color: "rgb(var(--fg-muted))",
|
||||
cursor: "pointer",
|
||||
fontSize: "10px",
|
||||
paddingRight: "6px",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
});
|
||||
|
||||
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
||||
@@ -304,6 +398,11 @@ export default function DiffEditor({
|
||||
onChange,
|
||||
onSave,
|
||||
onBlock,
|
||||
blockIncluded,
|
||||
blame,
|
||||
onBlameClick,
|
||||
navRef,
|
||||
onAddToChat,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
@@ -318,14 +417,24 @@ export default function DiffEditor({
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
/** Keep / Undo one changed block in git; absent = no bars. */
|
||||
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
|
||||
onBlock?: (action: BlockAction, block: EditorBlock) => void;
|
||||
/** Whether a block is ticked for the commit (its checkbox's state). */
|
||||
blockIncluded?: (block: EditorBlock) => boolean;
|
||||
/** Annotate: each line's last commit; absent = no gutter. */
|
||||
blame?: Blame;
|
||||
onBlameClick?: (sha: string) => void;
|
||||
/** Set to "go to the next (1) / previous (-1) changed block". */
|
||||
navRef?: { current: ((dir: 1 | -1) => void) | null };
|
||||
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
|
||||
onAddToChat?: (start: number, end: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave, onBlock, t });
|
||||
cb.current = { onChange, onSave, onBlock, t };
|
||||
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
|
||||
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
|
||||
const blameSlot = useRef(new Compartment()).current;
|
||||
|
||||
/** The diff view's review bar, as DOM: pinned to the visible right edge,
|
||||
* zero height so it never pushes lines (or a split view's alignment). */
|
||||
@@ -349,11 +458,13 @@ export default function DiffEditor({
|
||||
return b;
|
||||
};
|
||||
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
|
||||
const keep = chip(
|
||||
tr("changes.stageHunkShort"),
|
||||
tr("changes.stageHunk"),
|
||||
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
|
||||
);
|
||||
// The block's commit checkbox, a real one so it reads like the diff's.
|
||||
const include = bar.appendChild(document.createElement("input"));
|
||||
include.type = "checkbox";
|
||||
include.checked = cb.current.blockIncluded?.(block) ?? true;
|
||||
include.title = tr("changes.includeBlock");
|
||||
include.setAttribute("aria-label", tr("changes.includeBlock"));
|
||||
include.className = "h-3 w-3 cursor-pointer";
|
||||
// Two clicks, like every undo git cannot give back; disarms after 3 s.
|
||||
let timer = 0;
|
||||
const arm = (on: boolean) => {
|
||||
@@ -374,7 +485,7 @@ export default function DiffEditor({
|
||||
arm(true);
|
||||
timer = window.setTimeout(() => arm(false), 3000);
|
||||
};
|
||||
keep.onclick = () => cb.current.onBlock?.("keep", block);
|
||||
include.onchange = () => cb.current.onBlock?.("include", block);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -405,6 +516,28 @@ export default function DiffEditor({
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
}),
|
||||
blockBars(Text.of(original.split("\n")), buildBar),
|
||||
blameSlot.of([]),
|
||||
...(onAddToChat
|
||||
? [
|
||||
chatChip((start, end) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.dataset.testid = "lane-editor-add-to-chat";
|
||||
b.textContent = cb.current.t("changes.addToChat");
|
||||
b.className =
|
||||
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2";
|
||||
// Keep the editor's focus (and so its selection) through the click.
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||
// CodeMirror makes the returned element the `.cm-tooltip`, whose
|
||||
// themed rule above would strip the button's own border and fill.
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "cm-ah-chat";
|
||||
wrap.append(b);
|
||||
return wrap;
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
@@ -446,11 +579,47 @@ export default function DiffEditor({
|
||||
}
|
||||
v.focus();
|
||||
view.current = v;
|
||||
return destroy;
|
||||
if (navRef) {
|
||||
// The B side's chunks, unified or split: the cursor goes to the start
|
||||
// of the next / previous one and it scrolls into view.
|
||||
navRef.current = (dir) => {
|
||||
const ed = view.current;
|
||||
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
|
||||
if (!ed || !chunks.length) return;
|
||||
const at = ed.state.selection.main.head;
|
||||
const next =
|
||||
dir === 1
|
||||
? chunks.find((c) => c.fromB > at)
|
||||
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
|
||||
if (!next) return;
|
||||
ed.dispatch({
|
||||
selection: EditorSelection.cursor(next.fromB),
|
||||
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
|
||||
});
|
||||
ed.focus();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
if (navRef) navRef.current = null;
|
||||
destroy();
|
||||
};
|
||||
// One mount per view: the parent keys this component by file and shape.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Annotate on / off without remounting the view.
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({
|
||||
effects: blameSlot.reconfigure(
|
||||
blame
|
||||
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
|
||||
cb.current.onBlameClick?.(sha)
|
||||
)
|
||||
: []
|
||||
),
|
||||
});
|
||||
}, [blame, blameSlot]);
|
||||
|
||||
const bars = !!onBlock;
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({ effects: barsOn.of(bars) });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -247,7 +247,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
for (let i = 0; i < lines.length;) {
|
||||
if (lines[i]!.type === "ctx") {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -440,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
|
||||
* block's last line. A context rather than a prop because lines render from
|
||||
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (change: LaneDiffChange) => ReactNode;
|
||||
/** null = no bar; the anchors still mark each block for navigation. */
|
||||
actions: ((change: LaneDiffChange) => ReactNode) | null;
|
||||
changes: LaneDiffChange[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
@@ -457,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
|
||||
function HunkBar({ change }: { change: LaneDiffChange }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
if (!ctx?.actions) return null;
|
||||
const index = ctx.changes.indexOf(change);
|
||||
const total = ctx.changes.length;
|
||||
const nav =
|
||||
@@ -700,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["scrollTop", "scrollHeight", "clientHeight"],
|
||||
@@ -931,7 +931,9 @@ export function LaneDiffView({
|
||||
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
||||
// Provided without actions too: every block's first row carries its
|
||||
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
|
||||
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
|
||||
* pane above the ended ones) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
@@ -277,10 +278,12 @@ export function WorkspaceSessionList({
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
||||
);
|
||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||
// Live sessions first, ended ones under them — each group stays newest
|
||||
// first (the sort is stable over the server's order).
|
||||
const shown = (sessions ?? [])
|
||||
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
||||
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||
* count as edited in place (blue) rather than added or removed whole, and the
|
||||
* editor's own diff classes on a real mount, and the per-block Undo / Keep bar.
|
||||
* editor's own diff classes on a real mount, the per-block Undo / Keep bar,
|
||||
* and the Add to Chat chip on a selection.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
@@ -166,7 +168,39 @@ describe("DiffEditor", () => {
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
|
||||
it("puts an Undo / Keep bar under each block, by line, in place of Revert", async () => {
|
||||
it("annotates each line in a gutter, and hands a click its commit", async () => {
|
||||
const onBlameClick = vi.fn();
|
||||
const props = {
|
||||
value: "a\nnew",
|
||||
original: "a",
|
||||
lang: "plain",
|
||||
label: "Edit",
|
||||
revertLabel: "Revert",
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onBlameClick,
|
||||
};
|
||||
const sha = "f".repeat(40);
|
||||
const blame = {
|
||||
available: true as const,
|
||||
path: "x",
|
||||
commits: {
|
||||
[sha]: { short: "fffffff", author: "Vi", date: "2026-10-01T00:00:00Z", summary: "s" },
|
||||
},
|
||||
lines: [sha, null],
|
||||
};
|
||||
const { container, rerender } = render(<DiffEditor {...props} />);
|
||||
expect(container.querySelector(".cm-ah-blame")).toBeNull();
|
||||
rerender(<DiffEditor {...props} blame={blame} />);
|
||||
await waitFor(() => expect(container.querySelector(".cm-ah-blame")).not.toBeNull());
|
||||
// The first element is CodeMirror's invisible width spacer.
|
||||
const cells = [...container.querySelectorAll(".cm-ah-blame .cm-gutterElement span")].slice(1);
|
||||
expect(cells.map((c) => c.textContent)).toEqual(["2026-10-01 Vi", "Not committed yet"]);
|
||||
fireEvent.mouseDown(cells[0]!.parentElement!);
|
||||
expect(onBlameClick).toHaveBeenCalledWith(sha);
|
||||
});
|
||||
|
||||
it("puts an Undo + commit-checkbox bar under each block, by line, in place of Revert", async () => {
|
||||
const onBlock = vi.fn();
|
||||
const props = {
|
||||
value: "a\nB\nc\nnew\ne",
|
||||
@@ -177,7 +211,9 @@ describe("DiffEditor", () => {
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(<DiffEditor {...props} onBlock={onBlock} />);
|
||||
const { container, rerender } = render(
|
||||
<DiffEditor {...props} onBlock={onBlock} blockIncluded={(b) => b.oldFrom === 2} />
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
|
||||
);
|
||||
@@ -188,8 +224,10 @@ describe("DiffEditor", () => {
|
||||
const [first, second] = [
|
||||
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
|
||||
];
|
||||
fireEvent.click(second!.querySelectorAll("button")[1]!);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("keep", {
|
||||
const boxes = [first, second].map((b) => b!.querySelector<HTMLInputElement>("input")!);
|
||||
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
|
||||
fireEvent.click(boxes[1]!);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("include", {
|
||||
oldFrom: 4,
|
||||
oldCount: 1,
|
||||
newFrom: 4,
|
||||
@@ -216,4 +254,40 @@ describe("DiffEditor", () => {
|
||||
"false"
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
|
||||
const onAddToChat = vi.fn();
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"a\nb\nc\nd"}
|
||||
original={"a\nb\nc\nd"}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
);
|
||||
const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
|
||||
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull();
|
||||
// b..c, ending at the start of d: d is not taken.
|
||||
view.dispatch({ selection: { anchor: 2, head: 6 } });
|
||||
const chip = await waitFor(() => {
|
||||
const b = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="lane-editor-add-to-chat"]'
|
||||
);
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so
|
||||
// the button must not be the `.cm-tooltip` itself.
|
||||
expect(chip.classList.contains("cm-tooltip")).toBe(false);
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
* once, the range toggle changes which comparison is requested, History opens
|
||||
* one commit, and Files quick-opens a whole file. Also pins the per-file git
|
||||
* writes — including discard needing two clicks before the server hears
|
||||
* `confirm` — and the degrade for a cwd git cannot read.
|
||||
* `confirm` — the JetBrains commit (ticked files, Changes ticked and
|
||||
* Unversioned Files not by default, Commit and Push, Add to VCS / .gitignore,
|
||||
* a change block's own checkbox making a hunk-level commit), a file's
|
||||
* history, Annotate, next/previous file, and the degrade for a cwd git
|
||||
* cannot read.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -70,6 +74,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitLs: vi.fn(),
|
||||
gitTree: vi.fn(),
|
||||
gitTreeAll: vi.fn(),
|
||||
gitBlame: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -125,6 +130,11 @@ async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||
}
|
||||
|
||||
/** The overview row's checkbox for one path. */
|
||||
function tickOf(path: string) {
|
||||
return screen.getByRole("checkbox", { name: `Include ${path} in the commit` });
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
@@ -133,7 +143,6 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -280,60 +289,36 @@ describe("LaneChangesPanel", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages one file and asks the server for that path alone", async () => {
|
||||
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// Both rows offer it (one modified, one untracked); the first is the
|
||||
// modified file the assertion below names.
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stage!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
||||
await user.click(keep!); // the first row: server/lib/git-ops.js
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
||||
"server/lib/git-ops.js",
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
||||
// Only the untracked row offers it: a tracked file is already in VCS.
|
||||
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||
expect(more).toHaveLength(0);
|
||||
expect(tickOf("notes.txt")).not.toBeChecked();
|
||||
await user.click(add!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||
// And back: the row's button now offers the way out.
|
||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
|
||||
});
|
||||
|
||||
it("draws a never-stage file orange in the Files tree", async () => {
|
||||
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
|
||||
expect(group).toBePartiallyChecked();
|
||||
await user.click(group); // some ticked -> untick all
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(group); // -> tick all
|
||||
expect(tickOf("docs/API.md")).toBeChecked();
|
||||
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||
it("lists changes as a folder tree: a folder folds, and its box unticks every file in it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
@@ -341,16 +326,15 @@ describe("LaneChangesPanel", () => {
|
||||
const folder = within(list).getByTitle("server/lib");
|
||||
expect(folder).toHaveTextContent("1 file");
|
||||
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
|
||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||
name: "Tick every file in server/lib",
|
||||
});
|
||||
expect(box).toBeChecked();
|
||||
await user.click(box);
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(folder);
|
||||
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
await user.click(
|
||||
within(folder.parentElement!).getByRole("button", {
|
||||
name: "Stage every file in this folder",
|
||||
})
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
||||
@@ -373,7 +357,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||
await user.click(stage!);
|
||||
// The header's counts moved with the index: waiting out its 30s poll
|
||||
// would leave them wrong beside the list that just changed.
|
||||
@@ -420,43 +404,61 @@ describe("LaneChangesPanel", () => {
|
||||
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("stages every file the index lacks in one call, from above the list", async () => {
|
||||
it("commits exactly the ticked files: Changes ticked, Unversioned Files not, by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const actions = screen.getByTestId("lane-changes-actions");
|
||||
// Commit box and Stage all come BEFORE the file list.
|
||||
// The commit box comes BEFORE the file list.
|
||||
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
|
||||
// The already-staged docs/API.md is not sent again.
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
await user.click(tickOf("notes.txt"));
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: picked");
|
||||
await user.click(screen.getByRole("button", { name: "Commit (2)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: picked",
|
||||
paths: ["server/lib/git-ops.js", "notes.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
it("Commit and Push commits the ticked files, then pushes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "feat: ship");
|
||||
await user.click(screen.getByRole("button", { name: "Commit and Push" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(api.lanes.gitAction).mock.calls).toEqual([
|
||||
[7, "commit", { message: "feat: ship", paths: ["server/lib/git-ops.js", "docs/API.md"] }],
|
||||
[7, "push", {}],
|
||||
]);
|
||||
});
|
||||
|
||||
it("fills the commit box with the AI-suggested message", async () => {
|
||||
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTestId("lane-changes-suggest-message"));
|
||||
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
|
||||
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7, [
|
||||
"server/lib/git-ops.js",
|
||||
"docs/API.md",
|
||||
]);
|
||||
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
it("lists tracked changes apart from Unversioned Files, staged or not", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
|
||||
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
|
||||
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(tracked).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||
@@ -627,21 +629,21 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByTitle("server/lib/git-ops.js");
|
||||
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
||||
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
|
||||
|
||||
// Commit File: that file alone is ticked, and the message box has focus.
|
||||
await right();
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
vi.mocked(api.lanes.gitAction).mockClear();
|
||||
await user.click(menu().getByText("Commit File…"));
|
||||
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
|
||||
expect(tickOf("docs/API.md")).not.toBeChecked();
|
||||
expect(screen.getByLabelText("Commit message")).toHaveFocus();
|
||||
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
|
||||
|
||||
await right();
|
||||
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
||||
@@ -654,10 +656,123 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
|
||||
await right();
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
|
||||
);
|
||||
await user.click(menu().getByText("Copy relative path"));
|
||||
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
|
||||
|
||||
// Copy as Patch: that file's diff, read for its path, as a unified patch.
|
||||
vi.mocked(api.lanes.changes).mockResolvedValueOnce(
|
||||
changes({
|
||||
files: [{ ...changes().files![0]!, hunks: [HUNK] } as never],
|
||||
})
|
||||
);
|
||||
await right();
|
||||
await user.click(menu().getByText("Copy as Patch to Clipboard"));
|
||||
await waitFor(async () =>
|
||||
expect(await navigator.clipboard.readText()).toBe(
|
||||
[
|
||||
"diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js",
|
||||
"--- a/server/lib/git-ops.js",
|
||||
"+++ b/server/lib/git-ops.js",
|
||||
"@@ -1,2 +1,3 @@",
|
||||
" keep",
|
||||
"-old line",
|
||||
"+new line",
|
||||
"",
|
||||
].join("\n")
|
||||
)
|
||||
);
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("right-click on an unversioned file offers Add to VCS and Add to .gitignore", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByTitle("notes.txt");
|
||||
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(menu().getByText("Add to .gitignore"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "ignore", { paths: ["notes.txt"] });
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
expect(menu().getByText("Add to VCS")).toBeInTheDocument();
|
||||
// A tracked file is already in VCS, and git would not ignore it anyway.
|
||||
await user.keyboard("{Escape}");
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: within(list).getByTitle("docs/API.md"),
|
||||
});
|
||||
expect(menu().queryByText("Add to .gitignore")).toBeNull();
|
||||
expect(menu().queryByText("Add to VCS")).toBeNull();
|
||||
});
|
||||
|
||||
it("Show History lists one file's commits and opens the file at a commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: within(list).getByTitle("server/lib/git-ops.js"),
|
||||
});
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 50,
|
||||
file: "server/lib/git-ops.js",
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
|
||||
"server/lib/git-ops.js"
|
||||
);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
|
||||
range: "commit",
|
||||
sha: "a".repeat(40),
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("walks to the next file from an open one, in list order", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
await user.click(await screen.findByRole("button", { name: "Next file" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
|
||||
range: "work",
|
||||
path: "notes.txt",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Previous file" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("annotates the open file with who last changed each line", async () => {
|
||||
vi.mocked(api.lanes.gitBlame).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
commits: {},
|
||||
lines: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
await user.click(await screen.findByRole("button", { name: "Annotate" }));
|
||||
expect(api.lanes.gitBlame).toHaveBeenCalledWith(7, "server/lib/git-ops.js");
|
||||
expect(screen.getByRole("button", { name: "Annotate" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true"
|
||||
);
|
||||
});
|
||||
|
||||
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
||||
@@ -851,7 +966,10 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByTestId("lane-changes-amend"));
|
||||
await user.click(screen.getByRole("button", { name: "Amend" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", { sha: "aaaaaaa" })
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", {
|
||||
sha: "aaaaaaa",
|
||||
paths: ["server/lib/git-ops.js", "docs/API.md"],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -938,7 +1056,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
|
||||
it("colours the Files tree like JetBrains: modified blue, unversioned orange, added green", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -948,6 +1066,8 @@ describe("LaneChangesPanel", () => {
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
|
||||
{ name: "keep.txt", path: "keep.txt", dir: false },
|
||||
{ name: "add.txt", path: "add.txt", dir: false, status: "A " },
|
||||
{ name: "gone.txt", path: "gone.txt", dir: false, status: " D" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
@@ -958,9 +1078,12 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
expect(await row("a.txt")).toHaveClass("text-diff-mod");
|
||||
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
|
||||
expect(await row("new.txt")).toHaveClass("text-status-success");
|
||||
expect(await row("new.txt")).toHaveClass("text-kept");
|
||||
expect(await row("add.txt")).toHaveClass("text-status-success");
|
||||
expect(await row("gone.txt")).toHaveClass("text-fg-muted");
|
||||
expect(await row("src")).toHaveClass("text-diff-mod");
|
||||
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
|
||||
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
|
||||
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
|
||||
@@ -1069,18 +1192,6 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
|
||||
});
|
||||
|
||||
it("unstages every staged file in one call, in the same pinned bar as Stage all and commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const row = screen.getByTestId("lane-changes-actions");
|
||||
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
|
||||
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
|
||||
});
|
||||
|
||||
it("switches the diff to side-by-side", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -1157,22 +1268,14 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("commits the index from the overview, never the whole tree", async () => {
|
||||
it("cannot commit with nothing ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// The box exists only because a file is staged — with a clean index there
|
||||
// is nothing this button could mean.
|
||||
const box = await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
|
||||
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
|
||||
|
||||
expect(box).toBeInTheDocument();
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: the staged half",
|
||||
only: "staged",
|
||||
});
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
|
||||
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("drops a half-typed commit message on a lane switch", async () => {
|
||||
@@ -1185,24 +1288,10 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByLabelText("Commit message")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hides the commit box when nothing is staged", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
it("hides the commit box when there is nothing to commit", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await screen.findByText("Nothing uncommitted.");
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1292,22 +1381,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
};
|
||||
|
||||
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
// Two hunks on the tracked file; the untracked one has none to act on.
|
||||
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
|
||||
expect(stage).toHaveLength(2);
|
||||
await user.click(stage[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -9,2 +10,3 @@",
|
||||
block: 0,
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
|
||||
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
@@ -1350,13 +1424,35 @@ describe("LaneChangesPanel", () => {
|
||||
expect.stringContaining("1 of 2"),
|
||||
expect.stringContaining("2 of 2"),
|
||||
]);
|
||||
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,4 +1,5 @@",
|
||||
block: 1,
|
||||
context: 3,
|
||||
});
|
||||
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
|
||||
expect(boxes).toHaveLength(2);
|
||||
await user.click(boxes[1]!);
|
||||
expect(boxes[0]).toBeChecked();
|
||||
expect(boxes[1]).not.toBeChecked();
|
||||
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
// The second hunk's only block unticked: the file is partial.
|
||||
await user.click(
|
||||
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||
);
|
||||
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: half");
|
||||
await user.click(screen.getByRole("button", { name: "Commit (1)" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: half",
|
||||
paths: [],
|
||||
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("hangs one review bar under each actionable hunk, counting them", async () => {
|
||||
@@ -1398,14 +1494,18 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stages a whole file from its review header without folding it", async () => {
|
||||
it("unticks a whole file from its review header without folding it", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stageFile!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
// Every block of it follows the file.
|
||||
for (const box of screen.getAllByRole("checkbox", {
|
||||
name: "Include this change in the commit",
|
||||
}))
|
||||
expect(box).not.toBeChecked();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
@@ -1615,7 +1715,7 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||
await user.click(stage!);
|
||||
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
@@ -1732,6 +1832,8 @@ describe("a merge in progress (issue #45)", () => {
|
||||
// Not offered as an ordinary stage: that would mark it resolved blind.
|
||||
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
||||
// A partial commit cannot finish a merge: Complete is the way out.
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
|
||||
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* header: drag it to move the pane (drop on another pane's edge to split
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
|
||||
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
||||
* session there (X, `onStop`) without taking it out first. The grid shows
|
||||
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
||||
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
||||
* shows its state dot, as its tab does.
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
Minimize2,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -309,8 +311,11 @@ export function PaneGrid({
|
||||
toolLabel,
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
onStop,
|
||||
}: {
|
||||
layout: PaneLayout;
|
||||
/** Stops the session in `slot` (the header's X). */
|
||||
onStop?: (slot: number) => void;
|
||||
/** A live AI run's state for its header dot (none for a shell). */
|
||||
stateOf?: (r: RunHandle) => RunState | null;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
@@ -514,6 +519,18 @@ export function PaneGrid({
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{run && onStop && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-stop-${slot}`}
|
||||
title={t("panes.stop")}
|
||||
aria-label={t("panes.stop")}
|
||||
onClick={() => onStop(slot)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -557,6 +574,11 @@ export function PaneGrid({
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
{onStop && runOf(paneCtx.menu.item) && (
|
||||
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||
{t("panes.stop")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
{dividers.map((d) => (
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
|
||||
@@ -217,7 +218,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// under the canvas renderer (which only stops a glyph from shoving its
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too.
|
||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||
// Appearance may swap it for the rounded Maple Mono (also complete) or
|
||||
// the browser's own `monospace` (lib/terminalFont.ts).
|
||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||
// it on a PTY, where termios already does that translation for the streams
|
||||
@@ -229,7 +232,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
});
|
||||
@@ -510,6 +513,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
),
|
||||
[runId]
|
||||
);
|
||||
// The Workspace's focused pane takes those pastes even when the focus moved
|
||||
// without a click inside the terminal (a tab, Alt+1–4, the sidebar).
|
||||
useEffect(() => {
|
||||
if (autoFocus) notePaneFocus(runId);
|
||||
}, [autoFocus, runId]);
|
||||
|
||||
const runFind = (backwards = false) => {
|
||||
const q = find ?? "";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { pasteToRun } from "../../../lib/panePaste";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
const onDataHandlers: Array<(d: string) => void> = [];
|
||||
@@ -311,6 +311,25 @@ describe("TerminalView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("takes the workspace's paste once it is the focused pane, clicked into or not", () => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800);
|
||||
try {
|
||||
const both = (focus: number) => (
|
||||
<>
|
||||
<TerminalView runId="agenthub-lane-9" wsBaseUrl="ws://x" autoFocus={focus === 0} />
|
||||
<TerminalView runId="agenthub-lane-9-1" wsBaseUrl="ws://x" autoFocus={focus === 1} />
|
||||
</>
|
||||
);
|
||||
const { rerender } = render(both(0));
|
||||
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9");
|
||||
// Alt+2, a tab click: the Workspace moves the focus, the DOM may not.
|
||||
rerender(both(1));
|
||||
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9-1");
|
||||
} finally {
|
||||
width.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes the keyboard as soon as it mounts", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// opening a lane went nowhere until the user clicked the pane.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -11,6 +12,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
@@ -18,6 +20,12 @@ import { PetSettings } from "./PetSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
readTerminalFont,
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -65,6 +73,11 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
const ok = await confirmDialog({
|
||||
@@ -102,6 +115,37 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="terminal-font"
|
||||
title={t("terminalFont.title")}
|
||||
description={t("terminalFont.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("terminalFont.title")}
|
||||
value={termFont}
|
||||
onChange={pickTermFont}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
value: f,
|
||||
label: t(`terminalFont.names.${f}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
data-testid="terminal-font-preview"
|
||||
aria-label={t("terminalFont.preview")}
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
* whether the dashboard starts at boot/login through the per-user service
|
||||
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
||||
* stops the running server.
|
||||
* stops the running server. Beside it, **Quit AgentHub** (issue #65, 9router's
|
||||
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
|
||||
* stops the server, which stays stopped until `agenthub start` — or the next
|
||||
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AutostartStatus } from "../../lib/api";
|
||||
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function AutostartSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -36,6 +41,39 @@ export function AutostartSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const [tray, setTray] = useState<TrayStatus | null>(null);
|
||||
useEffect(() => {
|
||||
api.settings.tray
|
||||
.get()
|
||||
.then(setTray)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const changeTray = async (enabled: boolean) => {
|
||||
try {
|
||||
setTray(await api.settings.tray.set(enabled));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const [quitting, setQuitting] = useState(false);
|
||||
const quit = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("quit.confirmTitle"),
|
||||
message: t(status?.enabled ? "quit.confirmMessageAutostart" : "quit.confirmMessage"),
|
||||
confirmLabel: t("quit.button"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setQuitting(true);
|
||||
try {
|
||||
await api.settings.quit();
|
||||
toast.success(t("quit.done"));
|
||||
} catch (err) {
|
||||
setQuitting(false);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const note = !status
|
||||
? undefined
|
||||
: !status.available
|
||||
@@ -53,6 +91,28 @@ export function AutostartSettings() {
|
||||
disabled={!status?.available || status.foreign || saving}
|
||||
onChange={(v) => void change(v)}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("tray.label")}
|
||||
description={tray && !tray.available ? t("tray.unavailable") : t("tray.desc")}
|
||||
checked={!!tray?.available && tray.enabled}
|
||||
disabled={!tray?.available}
|
||||
onChange={(v) => void changeTray(v)}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("quit.label")}
|
||||
description={t("quit.desc")}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
data-testid="quit-agenthub"
|
||||
disabled={quitting}
|
||||
onClick={() => void quit()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
>
|
||||
{t("quit.button")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* @description The dashboard's own settings that belong to Claude Code, shown
|
||||
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
|
||||
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
|
||||
* plugin provides them), and a link to the Claude table on the Pricing page.
|
||||
* Changing CLAUDE_HOME asks first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,12 +15,17 @@ import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from
|
||||
import { api } from "../../lib/api";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { HomeDirSetting } from "./HomeDirSetting";
|
||||
import { PricingSettings } from "./PricingSettings";
|
||||
import { PricingLink } from "./PricingSettings";
|
||||
|
||||
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
type HooksInfo = {
|
||||
installed: boolean;
|
||||
path: string;
|
||||
hooks: Record<string, boolean>;
|
||||
source?: "plugin";
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
||||
* Claude Code's AgentHub settings: CLAUDE_HOME, hook status and the pricing link.
|
||||
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
||||
* the config it lists, which lives under that home).
|
||||
* @returns The three sections, each with its own id.
|
||||
@@ -205,13 +211,17 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
{hooks.source === "plugin" ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("hooks.plugin")}</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
<PricingLink vendor="claude" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,18 @@
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
DollarSign,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
@@ -191,6 +202,29 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
|
||||
export type PricingVendor = "claude" | "openai" | "google";
|
||||
|
||||
/**
|
||||
* A tool page's pointer to its vendor's table on the one Pricing page — the
|
||||
* table itself lives only there, so a price is never shown (or edited) twice.
|
||||
*/
|
||||
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="card flex items-center justify-between gap-4 p-5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<DollarSign className="h-4 w-4 text-fg-muted" />
|
||||
{t("pricingLink.title")}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||
</div>
|
||||
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
|
||||
{t("pricingLink.open")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Every mounted table's `load`: a change in one reloads them all, and loads
|
||||
// fired together share one request.
|
||||
const tables = new Set<() => void>();
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* @file UsersSettings.tsx
|
||||
* @description Settings → Users, server mode only and only for an admin or
|
||||
* analyst (docs/SERVER-MODE.md): every account with its role, state,
|
||||
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
|
||||
* (`GET /api/users`), and the audit log of staff reads of members' sessions
|
||||
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
||||
* `agenthub users …` on the host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function UsersSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch((e: Error) => setError(e.message));
|
||||
api.users
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
||||
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{u.role}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
||||
{audit?.length === 0 && (
|
||||
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
|
||||
)}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -73,8 +73,8 @@ describe("AppearanceSettings", () => {
|
||||
it("the System mode is checked after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("radio", { name: /System/ }));
|
||||
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
|
||||
await user.click(screen.getByRole("radio", { name: "System" }));
|
||||
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
});
|
||||
|
||||
@@ -84,4 +84,21 @@ describe("AppearanceSettings", () => {
|
||||
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
|
||||
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
||||
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
|
||||
await user.click(within(group).getByRole("radio", { name: "System font" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
|
||||
* first, says when start-at-login will bring the dashboard back, and only a
|
||||
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
|
||||
* switch, which goes straight to PUT /api/settings/tray.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { AutostartSettings } from "../AutostartSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import * as confirm from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
settings: {
|
||||
autostart: { get: vi.fn(), set: vi.fn() },
|
||||
tray: { get: vi.fn(), set: vi.fn() },
|
||||
quit: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const status = { available: true, kind: "systemd", enabled: true, foreign: false };
|
||||
const trayOff = { available: true, enabled: false, running: false };
|
||||
|
||||
describe("Quit AgentHub", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
|
||||
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
|
||||
vi.mocked(api.settings.quit).mockReset().mockResolvedValue({ ok: true, killed: 0 });
|
||||
});
|
||||
|
||||
it("does nothing when the ask is cancelled", async () => {
|
||||
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
|
||||
render(<AutostartSettings />);
|
||||
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
|
||||
expect(api.settings.quit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quits once confirmed, warning that start-at-login brings it back", async () => {
|
||||
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
|
||||
render(<AutostartSettings />);
|
||||
await waitFor(() => expect(api.settings.autostart.get).toHaveBeenCalled());
|
||||
await screen.findByRole("switch", { checked: true });
|
||||
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||
await waitFor(() => expect(api.settings.quit).toHaveBeenCalledTimes(1));
|
||||
expect(ask.mock.calls[0]![0].message).toMatch(/next boot\/login/);
|
||||
expect(screen.getByTestId("quit-agenthub")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("turns the status-area icon on from its switch", async () => {
|
||||
vi.mocked(api.settings.tray.set).mockResolvedValue({
|
||||
...trayOff,
|
||||
enabled: true,
|
||||
running: true,
|
||||
});
|
||||
render(<AutostartSettings />);
|
||||
await waitFor(() => expect(api.settings.tray.get).toHaveBeenCalled());
|
||||
const icon = screen.getAllByRole("switch")[1]!;
|
||||
await waitFor(() => expect(icon).toBeEnabled());
|
||||
fireEvent.click(icon);
|
||||
await waitFor(() => expect(api.settings.tray.set).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(icon).toBeChecked());
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,24 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.test.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||
* @description Settings → Claude Code → AgentHub: the hook status with
|
||||
* Reinstall (named as the plugin's when the plugin provides the hooks),
|
||||
* CLAUDE_HOME, and a link to the Claude table on the Pricing page — the
|
||||
* table itself is shown only there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
|
||||
|
||||
const render = (ui: ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
|
||||
function confirmYes() {
|
||||
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
|
||||
@@ -36,24 +40,9 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const RULE = {
|
||||
model_pattern: "claude-opus-5-5%",
|
||||
display_name: "Claude Opus 5.5",
|
||||
input_per_mtok: 4,
|
||||
output_per_mtok: 20,
|
||||
cache_read_per_mtok: 0.2,
|
||||
cache_write_per_mtok: 5,
|
||||
cache_write_1h_per_mtok: 8,
|
||||
fast_input_per_mtok: 8,
|
||||
fast_output_per_mtok: 40,
|
||||
updated_at: "2026-09-28 10:00:00",
|
||||
} as ModelPricing;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ pricing: [RULE] });
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
|
||||
vi.mocked(api.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -76,10 +65,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("ClaudeAgentHubSettings", () => {
|
||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||
it("renders hook status and CLAUDE_HOME, and links to the Claude prices", async () => {
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/pricing#pricing-claude"
|
||||
);
|
||||
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
|
||||
expect(api.pricing.list).not.toHaveBeenCalled();
|
||||
for (const id of ["hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
@@ -121,14 +115,19 @@ describe("ClaudeAgentHubSettings", () => {
|
||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||
});
|
||||
|
||||
it("resets pricing only on the second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
|
||||
vi.mocked(api.settings.info).mockResolvedValue({
|
||||
hooks: {
|
||||
installed: true,
|
||||
source: "plugin",
|
||||
path: "/home/u/.claude/settings.json",
|
||||
hooks: { UserPromptSubmit: true },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||
render(<ClaudeAgentHubSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||
const note = await screen.findByText(/provided by the agenthub plugin/i);
|
||||
const hooks = note.closest("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
|
||||
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user