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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user