feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages

- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
@@ -5,13 +5,16 @@
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Sun } from "lucide-react";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
@@ -61,6 +64,19 @@ export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const reset = async () => {
const ok = await confirmDialog({
title: t("appearance.resetTitle"),
message: t("appearance.resetBody"),
confirmLabel: t("appearance.reset"),
});
if (!ok) return;
setMode("dark");
setThemeFor("dark", "default");
setThemeFor("light", "default");
toast.success(t("appearance.resetDone"));
};
return (
<div className="card p-5 space-y-5">
<SettingsRow
@@ -116,6 +132,13 @@ export function AppearanceSettings() {
</div>
</div>
))}
<div className="flex justify-end border-t border-border pt-3">
<button type="button" onClick={() => void reset()} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("appearance.reset")}
</button>
</div>
</div>
);
}
@@ -4,7 +4,7 @@
* 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).
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -128,8 +128,9 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
id="claude-home"
title={t("claudeHome.title")}
description={t("claudeHome.description")}
note={t("cursorPathsNote")}
note={t("claudeHome.note")}
placeholder={t("claudeHome.placeholder")}
confirm={{ title: t("claudeHome.confirmTitle"), message: t("claudeHome.confirmBody") }}
load={() =>
api.settings.claudeHome
.get()
@@ -5,14 +5,18 @@
* server's refusal inline, the saved value while the field differs from it,
* and a warning when the real
* environment sets the variable (the environment wins at the next start, so a
* saved value would not survive a restart). The server validates and persists;
* this component only loads, submits and reports.
* saved value would not survive a restart). Enter saves; a caller may ask for
* a confirmation first (`confirm`). The server validates and persists; this
* component only loads, submits and reports.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow";
export interface HomeDirValue {
value: string;
@@ -29,6 +33,8 @@ interface Props {
/** Optional second line under the description. */
note?: string;
placeholder: string;
/** Ask this before saving (a change with wide effects, like CLAUDE_HOME). */
confirm?: { title: string; message?: string };
load: () => Promise<HomeDirValue>;
save: (path: string) => Promise<HomeDirValue>;
}
@@ -37,7 +43,16 @@ interface Props {
* An editable home directory with its own load/save cycle.
* @returns The section, or its skeleton-free shell while loading.
*/
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
export function HomeDirSetting({
id,
title,
description,
note,
placeholder,
confirm,
load,
save,
}: Props) {
const { t } = useTranslation("settings");
const [current, setCurrent] = useState<HomeDirValue | null>(null);
const [input, setInput] = useState("");
@@ -61,7 +76,8 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
}, []);
const handleSave = async () => {
if (!current || input === current.value) return;
if (!current || input === current.value || saving) return;
if (confirm && !(await confirmDialog({ ...confirm, confirmLabel: t("homeDir.save") }))) return;
setSaving(true);
setError(null);
try {
@@ -76,15 +92,18 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
};
return (
<section id={id} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{title}
</h3>
<p className="text-xs text-fg-muted mb-1">{description}</p>
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
<SettingsSection
id={id}
title={
<span className="flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-fg-muted" />
{title}
</span>
}
description={description}
>
<div className="space-y-4">
{note && <p className="text-[11px] text-fg-muted italic leading-snug">{note}</p>}
<div className="flex items-center gap-3">
<input
type="text"
@@ -94,10 +113,17 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
setInput(e.target.value);
setError(null);
}}
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void handleSave();
}
}}
className="input flex-1 font-mono"
placeholder={placeholder}
/>
<button
type="button"
onClick={handleSave}
disabled={saving || !current || input === current.value}
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
@@ -125,6 +151,6 @@ export function HomeDirSetting({ id, title, description, note, placeholder, load
</p>
)}
</div>
</section>
</SettingsSection>
);
}
+55 -6
View File
@@ -6,17 +6,31 @@
* uploading an image or importing a `.codex-pet.zip`. Everything here is the
* same state and calls as the mascot's right-click menu (`Sagi/usePets.ts`,
* `Sagi/prefs.ts`): the choice and size are per browser, the pets live on the
* server (`/api/pets`).
* server (`/api/pets`). Previews follow the OS reduced-motion preference; size
* and bubbles are disabled while the pet is hidden; a delete asks first; and
* **Reset to defaults** puts show / bubbles / size / pet back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Download, ExternalLink, Loader2, PackageOpen, Trash2, Upload } from "lucide-react";
import {
Check,
Download,
ExternalLink,
Loader2,
PackageOpen,
RotateCcw,
Trash2,
Upload,
} from "lucide-react";
import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
@@ -78,7 +92,9 @@ function PetCard({
disabled={disabled}
aria-label={deleteLabel}
title={deleteLabel}
className="absolute right-1.5 top-1.5 rounded p-1 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100"
className={`absolute right-1.5 top-1.5 rounded p-1 text-fg-muted transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 ${
selected ? "opacity-100" : "opacity-0"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -88,13 +104,14 @@ function PetCard({
}
function Preview({ pet, size }: { pet: PetView; size: number }) {
const reducedMotion = usePrefersReducedMotion();
return (
<PetSprite
id={pet.id}
kind={pet.kind}
mood="idle"
size={size}
reducedMotion={false}
reducedMotion={reducedMotion}
onBroken={() => {}}
/>
);
@@ -108,6 +125,22 @@ export function PetSettings() {
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
const sizePreview = Math.min(worn.size, 120);
const reducedMotion = usePrefersReducedMotion();
const resetDefaults = async () => {
const ok = await confirmDialog({
title: t("sagi.pet.reset"),
message: t("sagi.pet.resetMessage"),
confirmLabel: t("sagi.pet.reset"),
destructive: false,
});
if (!ok) return;
sagiPrefs.setEnabled(true);
sagiPrefs.setMuted(false);
sagiPrefs.setSize(PET_SIZE.default);
sagiPrefs.setPet({ id: "sagi" });
toast.success(t("sagi.pet.resetDone"));
};
return (
<div className="space-y-6">
@@ -123,6 +156,7 @@ export function PetSettings() {
onChange={(v) => sagiPrefs.setMuted(!v)}
label={t("sagi.bubbles")}
description={t("sagi.bubblesDesc")}
disabled={!worn.shown}
/>
<SettingsRow
label={t("sagi.pet.size")}
@@ -136,8 +170,9 @@ export function PetSettings() {
min={PET_SIZE.min}
max={PET_SIZE.max}
value={worn.size}
disabled={!worn.shown}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="w-40 accent-[rgb(var(--accent))]"
className="w-40 accent-[rgb(var(--accent))] disabled:opacity-50"
/>
<span className="w-12 text-right text-xs tabular-nums text-fg-muted">
{worn.size}px
@@ -152,9 +187,23 @@ export function PetSettings() {
{wornPet ? (
<Preview pet={wornPet} size={sizePreview} />
) : (
<SagiAvatar mood="idle" reducedMotion={false} size={sizePreview} />
<SagiAvatar mood="idle" reducedMotion={reducedMotion} size={sizePreview} />
)}
</div>
{worn.size > 120 && (
<p className="mt-1.5 text-[11px] text-fg-muted">
{t("sagi.pet.previewCapped", { size: worn.size })}
</p>
)}
{!worn.shown && (
<p className="mt-1.5 text-[11px] text-fg-muted">{t("sagi.pet.hiddenNote")}</p>
)}
<div className="mt-3 flex justify-end">
<button type="button" className="btn-ghost text-xs" onClick={() => void resetDefaults()}>
<RotateCcw className="h-3.5 w-3.5" />
{t("sagi.pet.reset")}
</button>
</div>
</div>
<div className="card p-5">
@@ -9,7 +9,10 @@
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
* rates, so only the Claude tab offers it; the server re-fetches any row the
* automatic sync owns (OpenAI's, Gemini's) straight after. A right-click on a
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu.
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu. A
* delete asks first (the sync never re-adds a deleted pattern). Every mounted
* table shares ONE list request and reloads together after any change, so the
* Pricing page's per-vendor tables never go stale against each other.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +26,9 @@ import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { getCurrentLocale } from "../../lib/format";
import { Skeleton } from "../Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { useArmed } from "../../hooks/useArmed";
import type { ModelPricing, WSMessage } from "../../lib/types";
interface EditRow {
@@ -185,6 +191,20 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
export type PricingVendor = "claude" | "openai" | "google";
// Every mounted table's `load`: a change in one reloads them all, and loads
// fired together share one request.
const tables = new Set<() => void>();
let inflight: ReturnType<typeof api.pricing.list> | null = null;
function listPricing() {
inflight ??= api.pricing.list().finally(() => {
inflight = null;
});
return inflight;
}
function reloadAllTables() {
for (const load of tables) load();
}
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
claude: /^claude-/,
openai: /^(gpt-|o\d|codex-)/,
@@ -216,7 +236,9 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const [error, setError] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
const [resetting, setResetting] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
// Reset Defaults is a two-click that disarms itself after 3 s.
const resetArm = useArmed();
const confirmReset = resetArm.isArmed("reset");
const ctx = useContextMenu<ModelPricing>();
// A menu item: close the menu, then act on the rule it was opened on.
const pick = (fn: (rule: ModelPricing) => unknown) => () => {
@@ -232,7 +254,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const load = useCallback(async () => {
try {
const res = await api.pricing.list();
const res = await listPricing();
setPricing(res.pricing);
setError(null);
} catch (err) {
@@ -244,6 +266,11 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
useEffect(() => {
load();
const reload = () => void load();
tables.add(reload);
return () => {
tables.delete(reload);
};
}, [load]);
// A remote source can bring its own pricing rules; reload when one syncs.
@@ -332,7 +359,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
setEditingPattern(null);
setAdding(false);
await load();
reloadAllTables();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedSave"));
} finally {
@@ -341,9 +368,17 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
};
const deleteRule = async (pattern: string) => {
if (
!(await confirmDialog({
title: t("pricing.deleteConfirmTitle", { pattern }),
message: t("pricing.deleteConfirmBody"),
confirmLabel: t("common:delete"),
}))
)
return;
try {
await api.pricing.delete(pattern);
await load();
reloadAllTables();
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
}
@@ -352,7 +387,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
const handleResetPricing = async () => {
setResetting(true);
setActionResult(null);
setConfirmReset(false);
resetArm.disarm();
try {
// Only the Claude tab offers the button, and it resets only Claude's rows.
const res = await api.settings.resetPricing("claude");
@@ -363,7 +398,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
}),
isError: false,
});
await load();
reloadAllTables();
} catch (err) {
setActionResult({
key: "reset-pricing",
@@ -591,7 +626,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
)}
{allowReset && (
<button
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
onClick={() => (confirmReset ? handleResetPricing() : resetArm.arm("reset"))}
disabled={isEditing || resetting}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmReset
@@ -749,7 +784,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
className="p-1.5 rounded-md text-fg-secondary hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
@@ -782,9 +817,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
<MenuItem disabled={isEditing} onSelect={pick(startEdit)}>
{t("common:edit")}
</MenuItem>
<MenuItem
onSelect={pick((rule) => void navigator.clipboard.writeText(rule.model_pattern))}
>
<MenuItem onSelect={pick((rule) => void copyText(rule.model_pattern))}>
{t("rowMenu.copyPattern")}
</MenuItem>
<MenuSeparator />
+27 -5
View File
@@ -2,25 +2,29 @@
* @file SettingsRow — the settings page's row primitives: a titled card
* (`SettingsSection`), a label/description | control row (`SettingsRow`), a
* switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`). Rows divide themselves with a hairline, none after the
* last, so a section is just its rows in order.
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* themselves with a hairline, none after the last, so a section is just its
* rows in order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Switch } from "../Switch";
import type { ReactNode } from "react";
import type { KeyboardEvent, ReactNode } from "react";
export function SettingsSection({
id,
title,
description,
children,
}: {
/** `<section id>` — a deep-link target. */
id?: string;
title: ReactNode;
description?: ReactNode;
children: ReactNode;
}) {
return (
<section className="card p-5">
<section id={id} className={id ? "card p-5 scroll-mt-24" : "card p-5"}>
<h3 className="text-sm font-semibold text-fg-primary">{title}</h3>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<div className="mt-2">{children}</div>
@@ -87,18 +91,36 @@ export function SettingsSegmented<T extends string>({
onChange: (v: T) => void;
ariaLabel?: string;
}) {
// A radio group's keyboard contract: one tab stop (the checked option), and
// the arrow keys move the selection — wrapping — and the focus with it.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!step || options.length === 0) return;
e.preventDefault();
const at = Math.max(
0,
options.findIndex((o) => o.value === value)
);
const next = (at + step + options.length) % options.length;
onChange(options[next]!.value);
(e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
};
return (
<div
role="radiogroup"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
>
{options.map((o) => (
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={
value === o.value || (i === 0 && !options.some((x) => x.value === value)) ? 0 : -1
}
onClick={() => onChange(o.value)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value
@@ -13,6 +13,12 @@ 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";
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
function confirmYes() {
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -89,8 +95,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.clear(input);
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off();
const hooks = container.querySelector("section#hooks") as HTMLElement;
await user.click(within(hooks).getByRole("button"));
@@ -106,8 +114,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
beforeEach(() => {
i18n.changeLanguage("en");
@@ -80,4 +81,42 @@ describe("HomeDirSetting", () => {
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
});
it("saves on Enter", async () => {
const user = userEvent.setup();
const { saveFn } = setup({ value: "/a", envOwned: false }, (p) =>
Promise.resolve({ value: p, envOwned: false })
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b{Enter}");
expect(saveFn).toHaveBeenCalledWith("/b");
});
it("asks first when given a confirm, and saves nothing on cancel", async () => {
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
const saveFn = vi.fn((p: string) => Promise.resolve({ value: p, envOwned: false }));
render(
<HomeDirSetting
id="x-home"
title="X_HOME"
description="desc"
placeholder="/path"
confirm={{ title: "Change X_HOME?", message: "sure?" }}
load={() => Promise.resolve({ value: "/a", envOwned: false })}
save={saveFn}
/>
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(pending?.title).toBe("Change X_HOME?"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(saveFn).not.toHaveBeenCalled();
off();
});
});
@@ -15,6 +15,18 @@ import i18n from "i18next";
import { PricingSettings } from "../PricingSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
/** Answer every confirm dialog with `ok`; returns the asked titles + unsubscribe. */
function answerConfirms(ok: boolean) {
const asked: PendingConfirm[] = [];
const off = subscribeConfirm((p) => {
if (!p) return;
asked.push(p);
queueMicrotask(() => settleConfirm(ok));
});
return { asked, off };
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -113,16 +125,56 @@ describe("PricingSettings", () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%");
await vi.waitFor(() => expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%"));
confirm.off();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(screen.getByDisplayValue("GPT-5.6 Sol")).toBeInTheDocument();
});
it("asks before deleting a rule, saying the sync will not bring it back", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(false);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i);
expect(api.pricing.delete).not.toHaveBeenCalled();
confirm.off();
});
it("refreshes every pricing table on the page after a change in one", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(
<>
<PricingSettings vendor="claude" id="a" />
<PricingSettings vendor="openai" id="b" />
</>
);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("claude-opus-5-5%");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
});
// The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
confirm.off();
});
});
@@ -0,0 +1,45 @@
/**
* @file SettingsRow.test.tsx
* @description `SettingsSegmented` is a real radio group: only the checked
* option is in the tab order, and the arrow keys move (and select) within it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { SettingsSegmented } from "../SettingsRow";
function Group() {
const [v, setV] = useState<"a" | "b" | "c">("a");
return (
<SettingsSegmented
ariaLabel="mode"
value={v}
onChange={setV}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
]}
/>
);
}
describe("SettingsSegmented", () => {
it("roves the tab stop and moves with the arrow keys", async () => {
const user = userEvent.setup();
render(<Group />);
const [a, b, c] = screen.getAllByRole("radio");
expect(a).toHaveAttribute("tabindex", "0");
expect(b).toHaveAttribute("tabindex", "-1");
a!.focus();
await user.keyboard("{ArrowRight}");
expect(b).toHaveAttribute("aria-checked", "true");
expect(b).toHaveFocus();
await user.keyboard("{ArrowLeft}{ArrowLeft}");
expect(c).toHaveAttribute("aria-checked", "true");
expect(c).toHaveFocus();
});
});