Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions desktop/frontend/src/__tests__/replay-pending-prompts.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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);
13 changes: 12 additions & 1 deletion desktop/frontend/src/components/ModelSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,20 @@ export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?:
const [open, setOpen] = useState(false);
const [models, setModels] = useState<ModelInfo[]>([]);
const [query, setQuery] = useState("");
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(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(() => {});
Expand Down
74 changes: 50 additions & 24 deletions desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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<void>) => {
const apply = useCallback(async (fn: () => Promise<void>) => {
setBusy(true);
setErr(null);
try {
Expand All @@ -104,8 +104,8 @@ export function SettingsPanel({
} finally {
setBusy(false);
}
};
const backgroundApply = async (fn: () => Promise<void>) => {
}, [reload, onChanged]);
const backgroundApply = useCallback(async (fn: () => Promise<void>) => {
setErr(null);
try {
await fn();
Expand All @@ -114,7 +114,7 @@ export function SettingsPanel({
} catch (e) {
setErr(String((e as Error)?.message ?? e));
}
};
}, [reload, onChanged]);

// Close on Esc
useEffect(() => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<HTMLButtonElement>(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;
Expand Down Expand Up @@ -3196,7 +3202,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
const [fetchingProvider, setFetchingProvider] = useState<string | null>(null);
const [fetchResults, setFetchResults] = useState<Record<string, ProviderFetchResult>>({});
const [modelDrafts, setModelDrafts] = useState<Record<string, ProviderModelDraft>>({});
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) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3922,9 +3934,13 @@ function providerGroupDescription(p: ProviderView, t: ReturnType<typeof useT>):
}

function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
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;
}
Expand Down Expand Up @@ -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 (
<div className="provider-model-chips">
{modelNames.slice(0, 8).map((model) => (
<span className="provider-model-chip" key={model}>{model}</span>
))}
{modelNames.length > 8 && (
<span className="provider-model-chip provider-model-chip--more">{t("settings.moreModels", { n: modelNames.length - 8 })}</span>
)}
</div>
);
});

function ProviderEditor({
initial,
kinds,
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -4299,14 +4332,7 @@ function ProviderEditor({
{modelNames.length > 0 && (
<div className="provider-card-block">
<div className="provider-card-block__label">{t("settings.availableModels")}</div>
<div className="provider-model-chips">
{modelNames.slice(0, 8).map((model) => (
<span className="provider-model-chip" key={model}>{model}</span>
))}
{modelNames.length > 8 && (
<span className="provider-model-chip provider-model-chip--more">{t("settings.moreModels", { n: modelNames.length - 8 })}</span>
)}
</div>
<ModelChips modelNames={modelNames} />
</div>
)}
<label className="set-label">{t("settings.manualModels")}</label>
Expand Down
3 changes: 3 additions & 0 deletions desktop/frontend/src/lib/useController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading