Skip to content

Commit 883cb18

Browse files
authored
🤖 fix: subscribe to provider config changes for instant UI updates (#879)
Settings UI now reacts immediately when API keys or base URLs are saved. ## Changes - Add `useProvidersConfig` hook that subscribes to backend `onConfigChanged` events - `ProvidersSection`/`ModelsSection` use hook instead of manual refresh after save - `ChatInput` subscribes to config changes for OpenAI key status (voice input) Previously the UI would only update after manual refresh or component remount. Now changes propagate instantly via the existing oRPC subscription mechanism. _Generated with `mux`_
1 parent 3b530f7 commit 883cb18

File tree

4 files changed

+187
-113
lines changed

4 files changed

+187
-113
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: 36 additions & 62 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,12 @@ interface EditingState {
2020

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

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-
3929
// Check if a model already exists (for duplicate prevention)
4030
const modelExists = useCallback(
4131
(provider: string, modelId: string, excludeOriginal?: string): boolean => {
@@ -46,7 +36,7 @@ export function ModelsSection() {
4636
[config]
4737
);
4838

49-
const handleAddModel = useCallback(async () => {
39+
const handleAddModel = useCallback(() => {
5040
if (!config || !newModel.provider || !newModel.modelId.trim()) return;
5141

5242
const trimmedModelId = newModel.modelId.trim();
@@ -59,40 +49,31 @@ export function ModelsSection() {
5949

6050
if (!api) return;
6151
setError(null);
62-
setSaving(true);
63-
try {
64-
const currentModels = config[newModel.provider]?.models ?? [];
65-
const updatedModels = [...currentModels, trimmedModelId];
6652

67-
await api.providers.setModels({ provider: newModel.provider, models: updatedModels });
53+
// Optimistic update - returns new models array for API call
54+
const updatedModels = updateModelsOptimistically(newModel.provider, (models) => [
55+
...models,
56+
trimmedModelId,
57+
]);
58+
setNewModel({ provider: "", modelId: "" });
6859

69-
// Refresh config
70-
const cfg = await api.providers.getConfig();
71-
setConfig(cfg ?? null);
72-
setNewModel({ provider: "", modelId: "" });
73-
} finally {
74-
setSaving(false);
75-
}
76-
}, [api, newModel, config, modelExists]);
60+
// Save in background
61+
void api.providers.setModels({ provider: newModel.provider, models: updatedModels });
62+
}, [api, newModel, config, modelExists, updateModelsOptimistically]);
7763

7864
const handleRemoveModel = useCallback(
79-
async (provider: string, modelId: string) => {
65+
(provider: string, modelId: string) => {
8066
if (!config || !api) return;
81-
setSaving(true);
82-
try {
83-
const currentModels = config[provider]?.models ?? [];
84-
const updatedModels = currentModels.filter((m) => m !== modelId);
8567

86-
await api.providers.setModels({ provider, models: updatedModels });
68+
// Optimistic update - returns new models array for API call
69+
const updatedModels = updateModelsOptimistically(provider, (models) =>
70+
models.filter((m) => m !== modelId)
71+
);
8772

88-
// Refresh config
89-
const cfg = await api.providers.getConfig();
90-
setConfig(cfg ?? null);
91-
} finally {
92-
setSaving(false);
93-
}
73+
// Save in background
74+
void api.providers.setModels({ provider, models: updatedModels });
9475
},
95-
[api, config]
76+
[api, config, updateModelsOptimistically]
9677
);
9778

9879
const handleStartEdit = useCallback((provider: string, modelId: string) => {
@@ -105,7 +86,7 @@ export function ModelsSection() {
10586
setError(null);
10687
}, []);
10788

108-
const handleSaveEdit = useCallback(async () => {
89+
const handleSaveEdit = useCallback(() => {
10990
if (!config || !editing || !api) return;
11091

11192
const trimmedModelId = editing.newModelId.trim();
@@ -123,26 +104,19 @@ export function ModelsSection() {
123104
}
124105

125106
setError(null);
126-
setSaving(true);
127-
try {
128-
const currentModels = config[editing.provider]?.models ?? [];
129-
const updatedModels = currentModels.map((m) =>
130-
m === editing.originalModelId ? trimmedModelId : m
131-
);
132107

133-
await api.providers.setModels({ provider: editing.provider, models: updatedModels });
108+
// Optimistic update - returns new models array for API call
109+
const updatedModels = updateModelsOptimistically(editing.provider, (models) =>
110+
models.map((m) => (m === editing.originalModelId ? trimmedModelId : m))
111+
);
112+
setEditing(null);
134113

135-
// Refresh config
136-
const cfg = await api.providers.getConfig();
137-
setConfig(cfg ?? null);
138-
setEditing(null);
139-
} finally {
140-
setSaving(false);
141-
}
142-
}, [api, editing, config, modelExists]);
114+
// Save in background
115+
void api.providers.setModels({ provider: editing.provider, models: updatedModels });
116+
}, [api, editing, config, modelExists, updateModelsOptimistically]);
143117

144118
// Show loading state while config is being fetched
145-
if (config === null) {
119+
if (loading || !config) {
146120
return (
147121
<div className="flex items-center justify-center gap-2 py-12">
148122
<Loader2 className="text-muted h-5 w-5 animate-spin" />
@@ -211,8 +185,8 @@ export function ModelsSection() {
211185
/>
212186
<button
213187
type="button"
214-
onClick={() => void handleAddModel()}
215-
disabled={saving || !newModel.provider || !newModel.modelId.trim()}
188+
onClick={handleAddModel}
189+
disabled={!newModel.provider || !newModel.modelId.trim()}
216190
className="bg-accent hover:bg-accent-dark disabled:bg-border-medium flex items-center gap-1 rounded px-2 py-1 text-xs text-white transition-colors disabled:cursor-not-allowed"
217191
>
218192
<Plus className="h-3.5 w-3.5" />
@@ -237,16 +211,16 @@ export function ModelsSection() {
237211
isEditing={isModelEditing}
238212
editValue={isModelEditing ? editing.newModelId : undefined}
239213
editError={isModelEditing ? error : undefined}
240-
saving={saving}
214+
saving={false}
241215
hasActiveEdit={editing !== null}
242216
onSetDefault={() => setDefaultModel(model.fullId)}
243217
onStartEdit={() => handleStartEdit(model.provider, model.modelId)}
244-
onSaveEdit={() => void handleSaveEdit()}
218+
onSaveEdit={handleSaveEdit}
245219
onCancelEdit={handleCancelEdit}
246220
onEditChange={(value) =>
247221
setEditing((prev) => (prev ? { ...prev, newModelId: value } : null))
248222
}
249-
onRemove={() => void handleRemoveModel(model.provider, model.modelId)}
223+
onRemove={() => handleRemoveModel(model.provider, model.modelId)}
250224
/>
251225
);
252226
})}

‎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

0 commit comments

Comments
 (0)