From 8a52b4ca21fd5860015245323999bfbdf5ec80c9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:38:00 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20instant=20UI=20updates=20?= =?UTF-8?q?when=20saving=20provider=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use optimistic updates for immediate feedback when saving API keys, base URLs, and custom models. 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 optimistic updates to avoid stale state races - ChatInput subscribes to config changes for OpenAI key status _Generated with `mux`_ --- src/browser/components/ChatInput/index.tsx | 31 ++++-- .../Settings/sections/ModelsSection.tsx | 98 +++++++------------ .../Settings/sections/ProvidersSection.tsx | 81 +++++++-------- src/browser/hooks/useProvidersConfig.ts | 90 +++++++++++++++++ 4 files changed, 187 insertions(+), 113 deletions(-) create mode 100644 src/browser/hooks/useProvidersConfig.ts diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 2f90052364..06cb87083a 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -413,25 +413,40 @@ export const ChatInput: React.FC = (props) => { }, [api]); // Check if OpenAI API key is configured (for voice input) + // Subscribe to config changes so key status updates immediately when set in Settings useEffect(() => { - let isMounted = true; + if (!api) return; + const abortController = new AbortController(); + const signal = abortController.signal; const checkOpenAIKey = async () => { try { - const config = await api?.providers.getConfig(); - if (isMounted) { + const config = await api.providers.getConfig(); + if (!signal.aborted) { setOpenAIKeySet(config?.openai?.apiKeySet ?? false); } - } catch (error) { - console.error("Failed to check OpenAI API key:", error); + } catch { + // Ignore errors fetching config } }; + // Initial fetch void checkOpenAIKey(); - return () => { - isMounted = false; - }; + // Subscribe to provider config changes via oRPC + (async () => { + try { + const iterator = await api.providers.onConfigChanged(undefined, { signal }); + for await (const _ of iterator) { + if (signal.aborted) break; + void checkOpenAIKey(); + } + } catch { + // Subscription cancelled via abort signal - expected on cleanup + } + })(); + + return () => abortController.abort(); }, [api]); // Allow external components (e.g., CommandPalette, Queued message edits) to insert text diff --git a/src/browser/components/Settings/sections/ModelsSection.tsx b/src/browser/components/Settings/sections/ModelsSection.tsx index 0a203b0d47..705c0359b4 100644 --- a/src/browser/components/Settings/sections/ModelsSection.tsx +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -1,11 +1,11 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useCallback } from "react"; import { Plus, Loader2 } from "lucide-react"; -import type { ProvidersConfigMap } from "../types"; import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { useModelLRU } from "@/browser/hooks/useModelLRU"; import { ModelRow } from "./ModelRow"; import { useAPI } from "@/browser/contexts/API"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; interface NewModelForm { provider: string; @@ -20,22 +20,12 @@ interface EditingState { export function ModelsSection() { const { api } = useAPI(); - const [config, setConfig] = useState(null); + const { config, loading, updateModelsOptimistically } = useProvidersConfig(); const [newModel, setNewModel] = useState({ provider: "", modelId: "" }); - const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(null); const [error, setError] = useState(null); const { defaultModel, setDefaultModel } = useModelLRU(); - // Load config on mount - useEffect(() => { - if (!api) return; - void (async () => { - const cfg = await api.providers.getConfig(); - setConfig(cfg ?? null); - })(); - }, [api]); - // Check if a model already exists (for duplicate prevention) const modelExists = useCallback( (provider: string, modelId: string, excludeOriginal?: string): boolean => { @@ -46,7 +36,7 @@ export function ModelsSection() { [config] ); - const handleAddModel = useCallback(async () => { + const handleAddModel = useCallback(() => { if (!config || !newModel.provider || !newModel.modelId.trim()) return; const trimmedModelId = newModel.modelId.trim(); @@ -59,40 +49,31 @@ export function ModelsSection() { if (!api) return; setError(null); - setSaving(true); - try { - const currentModels = config[newModel.provider]?.models ?? []; - const updatedModels = [...currentModels, trimmedModelId]; - await api.providers.setModels({ provider: newModel.provider, models: updatedModels }); + // Optimistic update - returns new models array for API call + const updatedModels = updateModelsOptimistically(newModel.provider, (models) => [ + ...models, + trimmedModelId, + ]); + setNewModel({ provider: "", modelId: "" }); - // Refresh config - const cfg = await api.providers.getConfig(); - setConfig(cfg ?? null); - setNewModel({ provider: "", modelId: "" }); - } finally { - setSaving(false); - } - }, [api, newModel, config, modelExists]); + // Save in background + void api.providers.setModels({ provider: newModel.provider, models: updatedModels }); + }, [api, newModel, config, modelExists, updateModelsOptimistically]); const handleRemoveModel = useCallback( - async (provider: string, modelId: string) => { + (provider: string, modelId: string) => { if (!config || !api) return; - setSaving(true); - try { - const currentModels = config[provider]?.models ?? []; - const updatedModels = currentModels.filter((m) => m !== modelId); - await api.providers.setModels({ provider, models: updatedModels }); + // Optimistic update - returns new models array for API call + const updatedModels = updateModelsOptimistically(provider, (models) => + models.filter((m) => m !== modelId) + ); - // Refresh config - const cfg = await api.providers.getConfig(); - setConfig(cfg ?? null); - } finally { - setSaving(false); - } + // Save in background + void api.providers.setModels({ provider, models: updatedModels }); }, - [api, config] + [api, config, updateModelsOptimistically] ); const handleStartEdit = useCallback((provider: string, modelId: string) => { @@ -105,7 +86,7 @@ export function ModelsSection() { setError(null); }, []); - const handleSaveEdit = useCallback(async () => { + const handleSaveEdit = useCallback(() => { if (!config || !editing || !api) return; const trimmedModelId = editing.newModelId.trim(); @@ -123,26 +104,19 @@ export function ModelsSection() { } setError(null); - setSaving(true); - try { - const currentModels = config[editing.provider]?.models ?? []; - const updatedModels = currentModels.map((m) => - m === editing.originalModelId ? trimmedModelId : m - ); - await api.providers.setModels({ provider: editing.provider, models: updatedModels }); + // Optimistic update - returns new models array for API call + const updatedModels = updateModelsOptimistically(editing.provider, (models) => + models.map((m) => (m === editing.originalModelId ? trimmedModelId : m)) + ); + setEditing(null); - // Refresh config - const cfg = await api.providers.getConfig(); - setConfig(cfg ?? null); - setEditing(null); - } finally { - setSaving(false); - } - }, [api, editing, config, modelExists]); + // Save in background + void api.providers.setModels({ provider: editing.provider, models: updatedModels }); + }, [api, editing, config, modelExists, updateModelsOptimistically]); // Show loading state while config is being fetched - if (config === null) { + if (loading || !config) { return (
@@ -211,8 +185,8 @@ export function ModelsSection() { />