Skip to content

Commit 8a42d15

Browse files
committed
🤖 fix: instant UI updates when saving provider settings
Use optimistic updates for immediate feedback when saving API keys/base URLs. Subscription still exists for external changes (CLI, other components). Changes: - Add useProvidersConfig hook with optimistic update support - ProvidersSection updates UI immediately on save, persists in background - ModelsSection uses hook for cross-component sync - ChatInput subscribes to config changes for OpenAI key status _Generated with `mux`_
1 parent 8f162fb commit 8a42d15

File tree

4 files changed

+137
-76
lines changed

4 files changed

+137
-76
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -413,25 +413,40 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
413413
}, [api]);
414414

415415
// Check if OpenAI API key is configured (for voice input)
416+
// Subscribe to config changes so key status updates immediately when set in Settings
416417
useEffect(() => {
417-
let isMounted = true;
418+
if (!api) return;
419+
const abortController = new AbortController();
420+
const signal = abortController.signal;
418421

419422
const checkOpenAIKey = async () => {
420423
try {
421-
const config = await api?.providers.getConfig();
422-
if (isMounted) {
424+
const config = await api.providers.getConfig();
425+
if (!signal.aborted) {
423426
setOpenAIKeySet(config?.openai?.apiKeySet ?? false);
424427
}
425-
} catch (error) {
426-
console.error("Failed to check OpenAI API key:", error);
428+
} catch {
429+
// Ignore errors fetching config
427430
}
428431
};
429432

433+
// Initial fetch
430434
void checkOpenAIKey();
431435

432-
return () => {
433-
isMounted = false;
434-
};
436+
// Subscribe to provider config changes via oRPC
437+
(async () => {
438+
try {
439+
const iterator = await api.providers.onConfigChanged(undefined, { signal });
440+
for await (const _ of iterator) {
441+
if (signal.aborted) break;
442+
void checkOpenAIKey();
443+
}
444+
} catch {
445+
// Subscription cancelled via abort signal - expected on cleanup
446+
}
447+
})();
448+
449+
return () => abortController.abort();
435450
}, [api]);
436451

437452
// Allow external components (e.g., CommandPalette, Queued message edits) to insert text

src/browser/components/Settings/sections/ModelsSection.tsx

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import React, { useState, useEffect, useCallback } from "react";
1+
import React, { useState, useCallback } from "react";
22
import { Plus, Loader2 } from "lucide-react";
3-
import type { ProvidersConfigMap } from "../types";
43
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";
54
import { KNOWN_MODELS } from "@/common/constants/knownModels";
65
import { useModelLRU } from "@/browser/hooks/useModelLRU";
76
import { ModelRow } from "./ModelRow";
87
import { useAPI } from "@/browser/contexts/API";
8+
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
99

1010
interface NewModelForm {
1111
provider: string;
@@ -20,22 +20,13 @@ interface EditingState {
2020

2121
export function ModelsSection() {
2222
const { api } = useAPI();
23-
const [config, setConfig] = useState<ProvidersConfigMap | null>(null);
23+
const { config, loading } = useProvidersConfig();
2424
const [newModel, setNewModel] = useState<NewModelForm>({ provider: "", modelId: "" });
2525
const [saving, setSaving] = useState(false);
2626
const [editing, setEditing] = useState<EditingState | null>(null);
2727
const [error, setError] = useState<string | null>(null);
2828
const { defaultModel, setDefaultModel } = useModelLRU();
2929

30-
// Load config on mount
31-
useEffect(() => {
32-
if (!api) return;
33-
void (async () => {
34-
const cfg = await api.providers.getConfig();
35-
setConfig(cfg ?? null);
36-
})();
37-
}, [api]);
38-
3930
// Check if a model already exists (for duplicate prevention)
4031
const modelExists = useCallback(
4132
(provider: string, modelId: string, excludeOriginal?: string): boolean => {
@@ -65,10 +56,7 @@ export function ModelsSection() {
6556
const updatedModels = [...currentModels, trimmedModelId];
6657

6758
await api.providers.setModels({ provider: newModel.provider, models: updatedModels });
68-
69-
// Refresh config
70-
const cfg = await api.providers.getConfig();
71-
setConfig(cfg ?? null);
59+
// Config refresh happens automatically via useProvidersConfig subscription
7260
setNewModel({ provider: "", modelId: "" });
7361
} finally {
7462
setSaving(false);
@@ -84,10 +72,7 @@ export function ModelsSection() {
8472
const updatedModels = currentModels.filter((m) => m !== modelId);
8573

8674
await api.providers.setModels({ provider, models: updatedModels });
87-
88-
// Refresh config
89-
const cfg = await api.providers.getConfig();
90-
setConfig(cfg ?? null);
75+
// Config refresh happens automatically via useProvidersConfig subscription
9176
} finally {
9277
setSaving(false);
9378
}
@@ -131,18 +116,15 @@ export function ModelsSection() {
131116
);
132117

133118
await api.providers.setModels({ provider: editing.provider, models: updatedModels });
134-
135-
// Refresh config
136-
const cfg = await api.providers.getConfig();
137-
setConfig(cfg ?? null);
119+
// Config refresh happens automatically via useProvidersConfig subscription
138120
setEditing(null);
139121
} finally {
140122
setSaving(false);
141123
}
142124
}, [api, editing, config, modelExists]);
143125

144126
// Show loading state while config is being fetched
145-
if (config === null) {
127+
if (loading || !config) {
146128
return (
147129
<div className="flex items-center justify-center gap-2 py-12">
148130
<Loader2 className="text-muted h-5 w-5 animate-spin" />

src/browser/components/Settings/sections/ProvidersSection.tsx

Lines changed: 38 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import React, { useState, useEffect, useCallback } from "react";
1+
import React, { useState, useCallback } from "react";
22
import { ChevronDown, ChevronRight, Check, X } from "lucide-react";
3-
import type { ProvidersConfigMap } from "../types";
43
import { SUPPORTED_PROVIDERS } from "@/common/constants/providers";
54
import type { ProviderName } from "@/common/constants/providers";
65
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
76
import { useAPI } from "@/browser/contexts/API";
7+
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
88

99
interface FieldConfig {
1010
key: string;
@@ -66,23 +66,13 @@ function getProviderFields(provider: ProviderName): FieldConfig[] {
6666

6767
export function ProvidersSection() {
6868
const { api } = useAPI();
69-
const [config, setConfig] = useState<ProvidersConfigMap>({});
69+
const { config, updateOptimistically } = useProvidersConfig();
7070
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
7171
const [editingField, setEditingField] = useState<{
7272
provider: string;
7373
field: string;
7474
} | null>(null);
7575
const [editValue, setEditValue] = useState("");
76-
const [saving, setSaving] = useState(false);
77-
78-
// Load config on mount
79-
useEffect(() => {
80-
if (!api) return;
81-
void (async () => {
82-
const cfg = await api.providers.getConfig();
83-
setConfig(cfg);
84-
})();
85-
}, [api]);
8676

8777
const handleToggleProvider = (provider: string) => {
8878
setExpandedProvider((prev) => (prev === provider ? null : provider));
@@ -102,41 +92,48 @@ export function ProvidersSection() {
10292
setEditValue("");
10393
};
10494

105-
const handleSaveEdit = useCallback(async () => {
95+
const handleSaveEdit = useCallback(() => {
10696
if (!editingField || !api) return;
10797

108-
setSaving(true);
109-
try {
110-
const { provider, field } = editingField;
111-
await api.providers.setProviderConfig({ provider, keyPath: [field], value: editValue });
98+
const { provider, field } = editingField;
11299

113-
// Refresh config
114-
const cfg = await api.providers.getConfig();
115-
setConfig(cfg);
116-
setEditingField(null);
117-
setEditValue("");
118-
} finally {
119-
setSaving(false);
100+
// Optimistic update for instant feedback
101+
if (field === "apiKey") {
102+
updateOptimistically(provider, { apiKeySet: editValue !== "" });
103+
} else if (field === "baseUrl") {
104+
updateOptimistically(provider, { baseUrl: editValue || undefined });
105+
} else if (field === "voucher") {
106+
updateOptimistically(provider, { voucherSet: editValue !== "" });
120107
}
121-
}, [api, editingField, editValue]);
108+
109+
setEditingField(null);
110+
setEditValue("");
111+
112+
// Save in background
113+
void api.providers.setProviderConfig({ provider, keyPath: [field], value: editValue });
114+
}, [api, editingField, editValue, updateOptimistically]);
122115

123116
const handleClearField = useCallback(
124-
async (provider: string, field: string) => {
117+
(provider: string, field: string) => {
125118
if (!api) return;
126-
setSaving(true);
127-
try {
128-
await api.providers.setProviderConfig({ provider, keyPath: [field], value: "" });
129-
const cfg = await api.providers.getConfig();
130-
setConfig(cfg);
131-
} finally {
132-
setSaving(false);
119+
120+
// Optimistic update for instant feedback
121+
if (field === "apiKey") {
122+
updateOptimistically(provider, { apiKeySet: false });
123+
} else if (field === "baseUrl") {
124+
updateOptimistically(provider, { baseUrl: undefined });
125+
} else if (field === "voucher") {
126+
updateOptimistically(provider, { voucherSet: false });
133127
}
128+
129+
// Save in background
130+
void api.providers.setProviderConfig({ provider, keyPath: [field], value: "" });
134131
},
135-
[api]
132+
[api, updateOptimistically]
136133
);
137134

138135
const isConfigured = (provider: string): boolean => {
139-
const providerConfig = config[provider];
136+
const providerConfig = config?.[provider];
140137
if (!providerConfig) return false;
141138

142139
// For Bedrock, check if any AWS credential field is set
@@ -155,7 +152,7 @@ export function ProvidersSection() {
155152
};
156153

157154
const getFieldValue = (provider: string, field: string): string | undefined => {
158-
const providerConfig = config[provider];
155+
const providerConfig = config?.[provider];
159156
if (!providerConfig) return undefined;
160157

161158
// For bedrock, check aws nested object for region
@@ -169,7 +166,7 @@ export function ProvidersSection() {
169166
};
170167

171168
const isFieldSet = (provider: string, field: string, fieldConfig: FieldConfig): boolean => {
172-
const providerConfig = config[provider];
169+
const providerConfig = config?.[provider];
173170
if (!providerConfig) return false;
174171

175172
if (fieldConfig.type === "secret") {
@@ -261,14 +258,13 @@ export function ProvidersSection() {
261258
className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1.5 font-mono text-xs focus:outline-none"
262259
autoFocus
263260
onKeyDown={(e) => {
264-
if (e.key === "Enter") void handleSaveEdit();
261+
if (e.key === "Enter") handleSaveEdit();
265262
if (e.key === "Escape") handleCancelEdit();
266263
}}
267264
/>
268265
<button
269266
type="button"
270-
onClick={() => void handleSaveEdit()}
271-
disabled={saving}
267+
onClick={handleSaveEdit}
272268
className="p-1 text-green-500 hover:text-green-400"
273269
>
274270
<Check className="h-4 w-4" />
@@ -296,8 +292,7 @@ export function ProvidersSection() {
296292
: fieldConfig.type === "secret" && fieldIsSet) && (
297293
<button
298294
type="button"
299-
onClick={() => void handleClearField(provider, fieldConfig.key)}
300-
disabled={saving}
295+
onClick={() => handleClearField(provider, fieldConfig.key)}
301296
className="text-muted hover:text-error text-xs"
302297
>
303298
Clear
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useEffect, useState, useCallback } from "react";
2+
import { useAPI } from "@/browser/contexts/API";
3+
import type { ProvidersConfigMap, ProviderConfigInfo } from "@/common/orpc/types";
4+
5+
/**
6+
* Hook to get provider config with automatic refresh on config changes.
7+
* Subscribes to the backend's onConfigChanged event for external changes.
8+
* Use updateOptimistically for instant UI feedback when saving.
9+
*/
10+
export function useProvidersConfig() {
11+
const { api } = useAPI();
12+
const [config, setConfig] = useState<ProvidersConfigMap | null>(null);
13+
const [loading, setLoading] = useState(true);
14+
15+
const refresh = useCallback(async () => {
16+
if (!api) return;
17+
try {
18+
const cfg = await api.providers.getConfig();
19+
setConfig(cfg);
20+
} catch {
21+
// Ignore errors fetching config
22+
} finally {
23+
setLoading(false);
24+
}
25+
}, [api]);
26+
27+
/**
28+
* Optimistically update local state for instant UI feedback.
29+
* Call this immediately when saving, before the API call completes.
30+
*/
31+
const updateOptimistically = useCallback(
32+
(provider: string, updates: Partial<ProviderConfigInfo>) => {
33+
setConfig((prev) => {
34+
if (!prev) return prev;
35+
return {
36+
...prev,
37+
[provider]: { ...prev[provider], ...updates },
38+
};
39+
});
40+
},
41+
[]
42+
);
43+
44+
useEffect(() => {
45+
if (!api) return;
46+
const abortController = new AbortController();
47+
const signal = abortController.signal;
48+
49+
// Initial fetch
50+
void refresh();
51+
52+
// Subscribe to provider config changes via oRPC (for external changes)
53+
(async () => {
54+
try {
55+
const iterator = await api.providers.onConfigChanged(undefined, { signal });
56+
for await (const _ of iterator) {
57+
if (signal.aborted) break;
58+
void refresh();
59+
}
60+
} catch {
61+
// Subscription cancelled via abort signal - expected on cleanup
62+
}
63+
})();
64+
65+
return () => abortController.abort();
66+
}, [api, refresh]);
67+
68+
return { config, loading, refresh, updateOptimistically };
69+
}

0 commit comments

Comments
 (0)