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
@@ -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 />