From 27fef703a3976335c9414f5910487d4a96f1105b Mon Sep 17 00:00:00 2001 From: Saurav Pandey Date: Wed, 29 Jul 2026 15:38:47 -0500 Subject: [PATCH] fix(desktop): stop provider config input resetting while typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Where to run" provider-probe effect depended on the whole `draft` and overwrote `providerConfig` with the schema defaults on every resolution. Because the probe is async, each keystroke re-fired it and a resolution landing mid-typing wiped the user's input — the Blox `workstation_name` field cleared itself as you typed. Key the effect on the selected provider's binary path (reading draft + onDraftChange through refs to avoid stale closures), and merge schema defaults *under* existing config via a new pure `applyProbeResult()` helper so user input always wins. Adds regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Saurav Pandey --- .../features/agents/ui/WhereToRunSection.tsx | 40 +++++++------- .../agents/ui/whereToRunIntent.test.mjs | 53 +++++++++++++++++++ .../features/agents/ui/whereToRunIntent.ts | 30 +++++++++++ 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/desktop/src/features/agents/ui/WhereToRunSection.tsx b/desktop/src/features/agents/ui/WhereToRunSection.tsx index f068eceec8..bf9d25e0d3 100644 --- a/desktop/src/features/agents/ui/WhereToRunSection.tsx +++ b/desktop/src/features/agents/ui/WhereToRunSection.tsx @@ -5,7 +5,11 @@ import { useBackendProvidersQuery } from "@/features/agents/hooks"; import { probeBackendProvider } from "@/shared/api/tauri"; import { ProviderConfigFields } from "./ProviderConfigFields"; -import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent"; +import { + applyProbeResult, + emptyWhereToRunDraft, + type WhereToRunDraft, +} from "./whereToRunIntent"; /** Optional remote-backend selector. Buzz shared compute is an LLM provider, not a run destination. */ export function WhereToRunSection({ @@ -26,32 +30,28 @@ export function WhereToRunSection({ [backendProviders, draft.runOn], ); + // Read live draft + callback through refs so the probe effect can key on the + // *provider identity* alone. Depending on `draft` here re-fired the effect on + // every keystroke, and each async probe resolution then reset the config — + // wiping whatever the user was typing. + const draftRef = React.useRef(draft); + draftRef.current = draft; + const onDraftChangeRef = React.useRef(onDraftChange); + onDraftChangeRef.current = onDraftChange; + + const selectedBinaryPath = selectedBackendProvider?.binaryPath; + React.useEffect(() => { - if (!isProviderMode || !selectedBackendProvider) { + if (!isProviderMode || !selectedBinaryPath) { setProbeError(null); return; } let cancelled = false; setProbeError(null); - void probeBackendProvider(selectedBackendProvider.binaryPath) + void probeBackendProvider(selectedBinaryPath) .then((result) => { if (cancelled) return; - const defaults: Record = {}; - const properties = - (result.config_schema as Record | undefined) - ?.properties ?? {}; - for (const [key, property] of Object.entries(properties) as [ - string, - Record, - ][]) { - if (property.default != null) - defaults[key] = String(property.default); - } - onDraftChange({ - ...draft, - probedProvider: result, - providerConfig: defaults, - }); + onDraftChangeRef.current(applyProbeResult(draftRef.current, result)); }) .catch((error: unknown) => { if (!cancelled) { @@ -61,7 +61,7 @@ export function WhereToRunSection({ return () => { cancelled = true; }; - }, [draft, isProviderMode, onDraftChange, selectedBackendProvider]); + }, [isProviderMode, selectedBinaryPath]); if (backendProviders.length === 0) return null; diff --git a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs index 500e9019f2..8b455f298f 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs +++ b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + applyProbeResult, canSubmitWhereToRun, emptyWhereToRunDraft, providerConfigComplete, @@ -59,3 +60,55 @@ test("provider draft resolves with coerced config values", () => { config: { region: "us", size: 3 }, }); }); + +// Regression: a probe resolving after the user has typed must NOT wipe the +// user's input. Previously the probe handler set `providerConfig` to the schema +// defaults unconditionally, so an async probe completing mid-typing cleared the +// field (e.g. the Blox workstation_name input reset itself while typing). +test("applyProbeResult preserves user-entered config when probe resolves", () => { + const draft = { + ...emptyWhereToRunDraft, + runOn: "blox", + providerConfig: { region: "234" }, + }; + const next = applyProbeResult(draft, probed); + assert.equal(next.providerConfig.region, "234"); + assert.equal(next.probedProvider, probed); +}); + +test("applyProbeResult seeds schema defaults on fresh provider selection", () => { + const probedWithDefault = { + ok: true, + config_schema: { + properties: { + workstation_name: { type: "string" }, + bundle_tag: { type: "string", default: "sprig-latest" }, + }, + required: ["workstation_name"], + }, + }; + const next = applyProbeResult( + { ...emptyWhereToRunDraft, runOn: "blox", providerConfig: {} }, + probedWithDefault, + ); + assert.equal(next.providerConfig.bundle_tag, "sprig-latest"); +}); + +test("applyProbeResult: user-entered value beats a schema default", () => { + const probedWithDefault = { + ok: true, + config_schema: { + properties: { bundle_tag: { type: "string", default: "sprig-latest" } }, + required: [], + }, + }; + const next = applyProbeResult( + { + ...emptyWhereToRunDraft, + runOn: "blox", + providerConfig: { bundle_tag: "custom" }, + }, + probedWithDefault, + ); + assert.equal(next.providerConfig.bundle_tag, "custom"); +}); diff --git a/desktop/src/features/agents/ui/whereToRunIntent.ts b/desktop/src/features/agents/ui/whereToRunIntent.ts index fcb3e82b7e..991a616775 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.ts +++ b/desktop/src/features/agents/ui/whereToRunIntent.ts @@ -15,6 +15,36 @@ export const emptyWhereToRunDraft: WhereToRunDraft = { probedProvider: null, }; +/** + * Merge a completed provider probe into the draft. Seeds schema defaults for + * config keys the user hasn't set yet, but preserves any value the user has + * already entered. + * + * This ordering matters: the probe is async, so it can resolve *after* the user + * has started typing. Overwriting `providerConfig` with the raw defaults there + * wiped the input mid-typing (e.g. the Blox `workstation_name` field resetting + * itself). Defaults are spread first so user-entered values win. + */ +export function applyProbeResult( + draft: WhereToRunDraft, + result: BackendProviderProbeResult, +): WhereToRunDraft { + const defaults: Record = {}; + const schema = result.config_schema as Record | undefined; + const properties = + (schema?.properties as + | Record> + | undefined) ?? {}; + for (const [key, property] of Object.entries(properties)) { + if (property.default != null) defaults[key] = String(property.default); + } + return { + ...draft, + probedProvider: result, + providerConfig: { ...defaults, ...draft.providerConfig }, + }; +} + export function providerConfigComplete(draft: WhereToRunDraft): boolean { if (draft.runOn === "local") return true; if (!draft.probedProvider) return false;