Skip to content

Commit c17e85d

Browse files
committed
🤖 fix: subscribe to provider config changes for instant UI updates
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 - ChatInput subscribes to config changes for OpenAI key status 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 8f162fb commit c17e85d

File tree

4 files changed

+89
-54
lines changed

4 files changed

+89
-54
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: 8 additions & 21 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,7 +66,7 @@ function getProviderFields(provider: ProviderName): FieldConfig[] {
6666

6767
export function ProvidersSection() {
6868
const { api } = useAPI();
69-
const [config, setConfig] = useState<ProvidersConfigMap>({});
69+
const { config } = useProvidersConfig();
7070
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
7171
const [editingField, setEditingField] = useState<{
7272
provider: string;
@@ -75,15 +75,6 @@ export function ProvidersSection() {
7575
const [editValue, setEditValue] = useState("");
7676
const [saving, setSaving] = useState(false);
7777

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]);
86-
8778
const handleToggleProvider = (provider: string) => {
8879
setExpandedProvider((prev) => (prev === provider ? null : provider));
8980
setEditingField(null);
@@ -109,10 +100,7 @@ export function ProvidersSection() {
109100
try {
110101
const { provider, field } = editingField;
111102
await api.providers.setProviderConfig({ provider, keyPath: [field], value: editValue });
112-
113-
// Refresh config
114-
const cfg = await api.providers.getConfig();
115-
setConfig(cfg);
103+
// Config refresh happens automatically via useProvidersConfig subscription
116104
setEditingField(null);
117105
setEditValue("");
118106
} finally {
@@ -126,8 +114,7 @@ export function ProvidersSection() {
126114
setSaving(true);
127115
try {
128116
await api.providers.setProviderConfig({ provider, keyPath: [field], value: "" });
129-
const cfg = await api.providers.getConfig();
130-
setConfig(cfg);
117+
// Config refresh happens automatically via useProvidersConfig subscription
131118
} finally {
132119
setSaving(false);
133120
}
@@ -136,7 +123,7 @@ export function ProvidersSection() {
136123
);
137124

138125
const isConfigured = (provider: string): boolean => {
139-
const providerConfig = config[provider];
126+
const providerConfig = config?.[provider];
140127
if (!providerConfig) return false;
141128

142129
// For Bedrock, check if any AWS credential field is set
@@ -155,7 +142,7 @@ export function ProvidersSection() {
155142
};
156143

157144
const getFieldValue = (provider: string, field: string): string | undefined => {
158-
const providerConfig = config[provider];
145+
const providerConfig = config?.[provider];
159146
if (!providerConfig) return undefined;
160147

161148
// For bedrock, check aws nested object for region
@@ -169,7 +156,7 @@ export function ProvidersSection() {
169156
};
170157

171158
const isFieldSet = (provider: string, field: string, fieldConfig: FieldConfig): boolean => {
172-
const providerConfig = config[provider];
159+
const providerConfig = config?.[provider];
173160
if (!providerConfig) return false;
174161

175162
if (fieldConfig.type === "secret") {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useEffect, useState, useCallback } from "react";
2+
import { useAPI } from "@/browser/contexts/API";
3+
import type { ProvidersConfigMap } 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 so updates are reflected immediately.
8+
*/
9+
export function useProvidersConfig() {
10+
const { api } = useAPI();
11+
const [config, setConfig] = useState<ProvidersConfigMap | null>(null);
12+
const [loading, setLoading] = useState(true);
13+
14+
const refresh = useCallback(async () => {
15+
if (!api) return;
16+
try {
17+
const cfg = await api.providers.getConfig();
18+
setConfig(cfg);
19+
} catch {
20+
// Ignore errors fetching config
21+
} finally {
22+
setLoading(false);
23+
}
24+
}, [api]);
25+
26+
useEffect(() => {
27+
if (!api) return;
28+
const abortController = new AbortController();
29+
const signal = abortController.signal;
30+
31+
// Initial fetch
32+
void refresh();
33+
34+
// Subscribe to provider config changes via oRPC
35+
(async () => {
36+
try {
37+
const iterator = await api.providers.onConfigChanged(undefined, { signal });
38+
for await (const _ of iterator) {
39+
if (signal.aborted) break;
40+
void refresh();
41+
}
42+
} catch {
43+
// Subscription cancelled via abort signal - expected on cleanup
44+
}
45+
})();
46+
47+
return () => abortController.abort();
48+
}, [api, refresh]);
49+
50+
return { config, loading, refresh };
51+
}

0 commit comments

Comments
 (0)