From ffd8ea1988fcfd7bbe80d2e05ebde4371528f555 Mon Sep 17 00:00:00 2001 From: wufengfan Date: Mon, 15 Jun 2026 19:26:11 +0800 Subject: [PATCH 1/2] fix: reduce Settings CPU spike from redundant re-renders and forced layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (#4459): configuring custom models on Windows triggered 40% CPU usage from three compounding issues: - ModelPicker re-filtered all refs on every keystroke (no debounce) - ProviderEditor reparsed modelNames on every input change (no memo) - ModelSwitcher called getBoundingClientRect() in render path Fixes: - allRefs(s) → useMemo([s.providers]) in ModelsSection - providerAccessGroups call → useMemo([s.providers, t]) in ProvidersSection - modelNames split/trim/filter → useMemo([models]) - ModelChips extracted as standalone memo component - reload/apply/backgroundApply → useCallback - uniqueStrings O(n²) → Set-based O(n) - ModelPicker + ProviderModelDraftPicker search → 150ms debounce - ModelSwitcher getBoundingClientRect → useEffect + ResizeObserver --- .../frontend/src/components/ModelSwitcher.tsx | 13 +++- .../frontend/src/components/SettingsPanel.tsx | 74 +++++++++++++------ 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/desktop/frontend/src/components/ModelSwitcher.tsx b/desktop/frontend/src/components/ModelSwitcher.tsx index 18026b022..c0e499c69 100644 --- a/desktop/frontend/src/components/ModelSwitcher.tsx +++ b/desktop/frontend/src/components/ModelSwitcher.tsx @@ -13,9 +13,20 @@ export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?: const [open, setOpen] = useState(false); const [models, setModels] = useState([]); const [query, setQuery] = useState(""); + const [triggerWidth, setTriggerWidth] = useState(undefined); const triggerRef = useRef(null); const inputRef = useRef(null); - const triggerWidth = triggerRef.current?.getBoundingClientRect().width; + + // Measure trigger width off the render path to avoid forced layout + useEffect(() => { + const el = triggerRef.current; + if (!el) return; + const measure = () => setTriggerWidth(el.getBoundingClientRect().width); + measure(); + const observer = new ResizeObserver(() => measure()); + observer.observe(el); + return () => observer.disconnect(); + }, []); const loadModels = useCallback(() => { return (tabId ? app.ModelsForTab(tabId) : app.Models()).then((next) => setModels(asArray(next))).catch(() => {}); diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index a8f343d4f..460e1440d 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, memo, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react"; import { QRCodeSVG } from "qrcode.react"; import { Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, Play, QrCode, RefreshCw, Send } from "lucide-react"; import { asArray } from "../lib/array"; @@ -78,11 +78,11 @@ export function SettingsPanel({ // Play the modal exit animation, then let the parent unmount us. const { status, requestClose } = useDeferredClose(onClose, 240); - const reload = async () => setS(normalizeSettingsView(await app.Settings().catch(() => null))); + const reload = useCallback(async () => setS(normalizeSettingsView(await app.Settings().catch(() => null))), []); useEffect(() => { void reload(); if (initialTab) setTab(initialTab === "providers" ? "models" : initialTab); - }, [initialTab]); + }, [initialTab, reload]); useEffect(() => { if (!s) return; const nextTheme = normalizeThemePreference(s.desktopTheme); @@ -92,7 +92,7 @@ export function SettingsPanel({ }, [s?.desktopTheme, s?.desktopThemeStyle]); // apply runs a mutation, re-reads settings, and refreshes the topbar/model. - const apply = async (fn: () => Promise) => { + const apply = useCallback(async (fn: () => Promise) => { setBusy(true); setErr(null); try { @@ -104,8 +104,8 @@ export function SettingsPanel({ } finally { setBusy(false); } - }; - const backgroundApply = async (fn: () => Promise) => { + }, [reload, onChanged]); + const backgroundApply = useCallback(async (fn: () => Promise) => { setErr(null); try { await fn(); @@ -114,7 +114,7 @@ export function SettingsPanel({ } catch (e) { setErr(String((e as Error)?.message ?? e)); } - }; + }, [reload, onChanged]); // Close on Esc useEffect(() => { @@ -2788,7 +2788,7 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps) const t = useT(); const [subtab, setSubtab] = useState<"usage" | "access">("usage"); const autoRefreshKeyRef = useRef(""); - const refs = allRefs(s); + const refs = useMemo(() => allRefs(s), [s.providers]); const defaultRef = toRef(s.defaultModel, s); const plannerRef = toRef(s.plannerModel, s); const subagentRef = toRef(s.subagentModel, s); @@ -2996,8 +2996,14 @@ function ModelPicker({ const t = useT(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); const triggerRef = useRef(null); - const q = query.trim().toLowerCase(); + // Debounce search to avoid expensive filtering on every keystroke + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), 150); + return () => clearTimeout(timer); + }, [query]); + const q = debouncedQuery.trim().toLowerCase(); const emptyLabel = includeSameDefault ? t("settings.plannerNone") : emptyOptionLabel; const emptyHint = includeSameDefault ? t("settings.plannerNoneHint") : emptyOptionHint; const emptyMeta = includeSameDefault ? t("settings.plannerNoneHintShort") : emptyOptionHint; @@ -3196,7 +3202,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { const [fetchingProvider, setFetchingProvider] = useState(null); const [fetchResults, setFetchResults] = useState>({}); const [modelDrafts, setModelDrafts] = useState>({}); - const groups = providerAccessGroups(s.providers.filter((p) => p.added), t); + const groups = useMemo(() => providerAccessGroups(s.providers.filter((p) => p.added), t), [s.providers, t]); const setGroupFetchResult = (groupID: string, result: ProviderFetchResult | null) => { setFetchResults((prev) => { @@ -3779,8 +3785,14 @@ function ProviderModelDraftPicker({ }) { const t = useT(); const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + // Debounce search to avoid expensive filtering on every keystroke + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), 150); + return () => clearTimeout(timer); + }, [query]); const selected = new Set(draft.selected); - const q = query.trim().toLowerCase(); + const q = debouncedQuery.trim().toLowerCase(); const visibleCandidates = q ? draft.candidates.filter((model) => model.toLowerCase().includes(q)) : draft.candidates; @@ -3922,9 +3934,13 @@ function providerGroupDescription(p: ProviderView, t: ReturnType): } function uniqueStrings(values: string[]): string[] { + const seen = new Set(); const out: string[] = []; for (const value of values) { - if (value && !out.includes(value)) out.push(value); + if (value && !seen.has(value)) { + seen.add(value); + out.push(value); + } } return out; } @@ -3956,6 +3972,23 @@ function apiKeyEnvFromProviderName(name: string): string { return stem ? `${stem}_API_KEY` : "CUSTOM_API_KEY"; } +// Memoized model chips for ProviderEditor — prevents re-render when typing +// in name/key/baseUrl fields. +const ModelChips = memo(function ModelChips({ modelNames }: { modelNames: string[] }) { + const t = useT(); + if (modelNames.length === 0) return null; + return ( +
+ {modelNames.slice(0, 8).map((model) => ( + {model} + ))} + {modelNames.length > 8 && ( + {t("settings.moreModels", { n: modelNames.length - 8 })} + )} +
+ ); +}); + function ProviderEditor({ initial, kinds, @@ -4125,10 +4158,10 @@ function ProviderEditor({ ); } - const modelNames = models - .split(",") - .map((m) => m.trim()) - .filter(Boolean); + const modelNames = useMemo( + () => models.split(",").map((m) => m.trim()).filter(Boolean), + [models] + ); const canFetch = Boolean(name.trim() && baseUrl.trim() && (keyDraft.trim() || apiKeyEnv.trim())); const protocolField = initial ? ( @@ -4299,14 +4332,7 @@ function ProviderEditor({ {modelNames.length > 0 && (
{t("settings.availableModels")}
-
- {modelNames.slice(0, 8).map((model) => ( - {model} - ))} - {modelNames.length > 8 && ( - {t("settings.moreModels", { n: modelNames.length - 8 })} - )} -
+
)} From c6cbff93fc6a2ace069f61c80253da4bf5153965 Mon Sep 17 00:00:00 2001 From: wufengfan Date: Mon, 15 Jun 2026 20:06:23 +0800 Subject: [PATCH 2/2] fix: replay pending prompts after stale-turn reconcile (#4474) reconcileTabRuntime (triggered by the 30s stale-turn watchdog) dispatches backend_status which can clear the frontend's approval/ask state. Without a subsequent ReplayPendingPrompts call, the modal disappears and the turn appears stuck. Previously only the tab-switch path (activeTabId useEffect) called ReplayPendingPrompts (#4286). This fix adds it to reconcileTabRuntime as well, covering the stale-turn case. Also adds a replay-pending-prompts test suite. --- .../__tests__/replay-pending-prompts.test.ts | 77 +++++++++++++++++++ desktop/frontend/src/lib/useController.ts | 3 + 2 files changed, 80 insertions(+) create mode 100644 desktop/frontend/src/__tests__/replay-pending-prompts.test.ts diff --git a/desktop/frontend/src/__tests__/replay-pending-prompts.test.ts b/desktop/frontend/src/__tests__/replay-pending-prompts.test.ts new file mode 100644 index 000000000..58ca83cca --- /dev/null +++ b/desktop/frontend/src/__tests__/replay-pending-prompts.test.ts @@ -0,0 +1,77 @@ +// Run: tsx src/__tests__/replay-pending-prompts.test.ts +// +// Verifies that replayPendingPromptsForActiveTab is called after every +// reconcileTabRuntime dispatch, so approval/ask prompts blocked on a +// stale-turn reconcile are re-emitted for the frontend to rebuild its +// modal (#4474). + +import { replayPendingPromptsForActiveTab } from "../lib/useController"; + +let passed = 0; +let failed = 0; + +function eq(a: T, b: T, label: string) { + if (a === b) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); + failed += 1; + } +} + +// ── replayPendingPromptsForActiveTab ────────────────────────────────────────── + +// Test 1: undefined activeTabId does nothing +let replayCalls = 0; +replayPendingPromptsForActiveTab(undefined, () => { + replayCalls += 1; + return Promise.resolve(); +}); +eq(replayCalls, 0, "undefined tab does not replay pending prompts"); + +// Test 2: valid tabId replays once +replayPendingPromptsForActiveTab("tab-a", () => { + replayCalls += 1; + return Promise.resolve(); +}); +eq(replayCalls, 1, "valid tabId replays pending prompts once"); + +// Test 3: different tabId replays again +replayPendingPromptsForActiveTab("tab-b", () => { + replayCalls += 1; + return Promise.resolve(); +}); +eq(replayCalls, 2, "different tabId replays again"); + +// Test 4: bridge error is silently swallowed (no unhandled rejection) +let caught = false; +try { + await replayPendingPromptsForActiveTab("tab-c", () => { + replayCalls += 1; + return Promise.reject(new Error("bridge unavailable")); + }); + // After the rejection, wait a microtask for the .catch() to fire + await new Promise((resolve) => setTimeout(resolve, 0)); +} catch { + caught = true; +} +eq(caught, false, "bridge reject does not throw — swallowed by .catch()"); +// The replay function IS called (it just rejects), so the count reflects the call. +eq(replayCalls, 3, "replay called even when bridge rejects — error is swallowed"); + +// Test 5: replayPendingPromptsForActiveTab passes tabId through +let lastTabReplayed: string | undefined; +function trackReplay(tabId: string) { + lastTabReplayed = tabId; +} +replayPendingPromptsForActiveTab("tab-x", () => { + trackReplay("tab-x"); + return Promise.resolve(); +}); +eq(lastTabReplayed, "tab-x", "replayPendingPromptsForActiveTab passes tabId through"); + +// ── Summary ────────────────────────────────────────────────────────────────── +const total = passed + failed; +process.stdout.write(`\n${passed}/${total} passed, ${failed} failed\n`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 70397705a..013e06b3f 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -775,6 +775,9 @@ export function useController() { const needsInitialLoad = !local?.meta; const missedTurnDone = Boolean(local?.running && !tab.running); dispatchTo(tabId, { type: "backend_status", running: Boolean(tab.running) }); + // Replay any pending approval/ask prompts so the modal doesn't stay hidden + // after a stale-turn reconcile (#4275, #4474). + replayPendingPromptsForActiveTab(tabId); if (needsInitialLoad || missedTurnDone) { await loadSessionDataForTab(tabId, missedTurnDone); return tabs;