Browse Source

Merge pull request #979 from System233/patch-input-boxes

Fix input box revert issue and configuration loss during profile switch #955
Matt Rubens 10 months ago
parent
commit
17be7c18f8

+ 90 - 63
src/core/config/ConfigManager.ts

@@ -33,29 +33,38 @@ export class ConfigManager {
 		return Math.random().toString(36).substring(2, 15)
 	}
 
+	// Synchronize readConfig/writeConfig operations to avoid data loss.
+	private _lock = Promise.resolve()
+	private lock<T>(cb: () => Promise<T>) {
+		const next = this._lock.then(cb)
+		this._lock = next.catch(() => {}) as Promise<void>
+		return next
+	}
 	/**
 	 * Initialize config if it doesn't exist
 	 */
 	async initConfig(): Promise<void> {
 		try {
-			const config = await this.readConfig()
-			if (!config) {
-				await this.writeConfig(this.defaultConfig)
-				return
-			}
+			return await this.lock(async () => {
+				const config = await this.readConfig()
+				if (!config) {
+					await this.writeConfig(this.defaultConfig)
+					return
+				}
 
-			// Migrate: ensure all configs have IDs
-			let needsMigration = false
-			for (const [name, apiConfig] of Object.entries(config.apiConfigs)) {
-				if (!apiConfig.id) {
-					apiConfig.id = this.generateId()
-					needsMigration = true
+				// Migrate: ensure all configs have IDs
+				let needsMigration = false
+				for (const [name, apiConfig] of Object.entries(config.apiConfigs)) {
+					if (!apiConfig.id) {
+						apiConfig.id = this.generateId()
+						needsMigration = true
+					}
 				}
-			}
 
-			if (needsMigration) {
-				await this.writeConfig(config)
-			}
+				if (needsMigration) {
+					await this.writeConfig(config)
+				}
+			})
 		} catch (error) {
 			throw new Error(`Failed to initialize config: ${error}`)
 		}
@@ -66,12 +75,14 @@ export class ConfigManager {
 	 */
 	async listConfig(): Promise<ApiConfigMeta[]> {
 		try {
-			const config = await this.readConfig()
-			return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
-				name,
-				id: apiConfig.id || "",
-				apiProvider: apiConfig.apiProvider,
-			}))
+			return await this.lock(async () => {
+				const config = await this.readConfig()
+				return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
+					name,
+					id: apiConfig.id || "",
+					apiProvider: apiConfig.apiProvider,
+				}))
+			})
 		} catch (error) {
 			throw new Error(`Failed to list configs: ${error}`)
 		}
@@ -82,13 +93,15 @@ export class ConfigManager {
 	 */
 	async saveConfig(name: string, config: ApiConfiguration): Promise<void> {
 		try {
-			const currentConfig = await this.readConfig()
-			const existingConfig = currentConfig.apiConfigs[name]
-			currentConfig.apiConfigs[name] = {
-				...config,
-				id: existingConfig?.id || this.generateId(),
-			}
-			await this.writeConfig(currentConfig)
+			return await this.lock(async () => {
+				const currentConfig = await this.readConfig()
+				const existingConfig = currentConfig.apiConfigs[name]
+				currentConfig.apiConfigs[name] = {
+					...config,
+					id: existingConfig?.id || this.generateId(),
+				}
+				await this.writeConfig(currentConfig)
+			})
 		} catch (error) {
 			throw new Error(`Failed to save config: ${error}`)
 		}
@@ -99,17 +112,19 @@ export class ConfigManager {
 	 */
 	async loadConfig(name: string): Promise<ApiConfiguration> {
 		try {
-			const config = await this.readConfig()
-			const apiConfig = config.apiConfigs[name]
+			return await this.lock(async () => {
+				const config = await this.readConfig()
+				const apiConfig = config.apiConfigs[name]
 
-			if (!apiConfig) {
-				throw new Error(`Config '${name}' not found`)
-			}
+				if (!apiConfig) {
+					throw new Error(`Config '${name}' not found`)
+				}
 
-			config.currentApiConfigName = name
-			await this.writeConfig(config)
+				config.currentApiConfigName = name
+				await this.writeConfig(config)
 
-			return apiConfig
+				return apiConfig
+			})
 		} catch (error) {
 			throw new Error(`Failed to load config: ${error}`)
 		}
@@ -120,18 +135,20 @@ export class ConfigManager {
 	 */
 	async deleteConfig(name: string): Promise<void> {
 		try {
-			const currentConfig = await this.readConfig()
-			if (!currentConfig.apiConfigs[name]) {
-				throw new Error(`Config '${name}' not found`)
-			}
+			return await this.lock(async () => {
+				const currentConfig = await this.readConfig()
+				if (!currentConfig.apiConfigs[name]) {
+					throw new Error(`Config '${name}' not found`)
+				}
 
-			// Don't allow deleting the default config
-			if (Object.keys(currentConfig.apiConfigs).length === 1) {
-				throw new Error(`Cannot delete the last remaining configuration.`)
-			}
+				// Don't allow deleting the default config
+				if (Object.keys(currentConfig.apiConfigs).length === 1) {
+					throw new Error(`Cannot delete the last remaining configuration.`)
+				}
 
-			delete currentConfig.apiConfigs[name]
-			await this.writeConfig(currentConfig)
+				delete currentConfig.apiConfigs[name]
+				await this.writeConfig(currentConfig)
+			})
 		} catch (error) {
 			throw new Error(`Failed to delete config: ${error}`)
 		}
@@ -142,13 +159,15 @@ export class ConfigManager {
 	 */
 	async setCurrentConfig(name: string): Promise<void> {
 		try {
-			const currentConfig = await this.readConfig()
-			if (!currentConfig.apiConfigs[name]) {
-				throw new Error(`Config '${name}' not found`)
-			}
+			return await this.lock(async () => {
+				const currentConfig = await this.readConfig()
+				if (!currentConfig.apiConfigs[name]) {
+					throw new Error(`Config '${name}' not found`)
+				}
 
-			currentConfig.currentApiConfigName = name
-			await this.writeConfig(currentConfig)
+				currentConfig.currentApiConfigName = name
+				await this.writeConfig(currentConfig)
+			})
 		} catch (error) {
 			throw new Error(`Failed to set current config: ${error}`)
 		}
@@ -159,8 +178,10 @@ export class ConfigManager {
 	 */
 	async hasConfig(name: string): Promise<boolean> {
 		try {
-			const config = await this.readConfig()
-			return name in config.apiConfigs
+			return await this.lock(async () => {
+				const config = await this.readConfig()
+				return name in config.apiConfigs
+			})
 		} catch (error) {
 			throw new Error(`Failed to check config existence: ${error}`)
 		}
@@ -171,12 +192,14 @@ export class ConfigManager {
 	 */
 	async setModeConfig(mode: Mode, configId: string): Promise<void> {
 		try {
-			const currentConfig = await this.readConfig()
-			if (!currentConfig.modeApiConfigs) {
-				currentConfig.modeApiConfigs = {}
-			}
-			currentConfig.modeApiConfigs[mode] = configId
-			await this.writeConfig(currentConfig)
+			return await this.lock(async () => {
+				const currentConfig = await this.readConfig()
+				if (!currentConfig.modeApiConfigs) {
+					currentConfig.modeApiConfigs = {}
+				}
+				currentConfig.modeApiConfigs[mode] = configId
+				await this.writeConfig(currentConfig)
+			})
 		} catch (error) {
 			throw new Error(`Failed to set mode config: ${error}`)
 		}
@@ -187,8 +210,10 @@ export class ConfigManager {
 	 */
 	async getModeConfigId(mode: Mode): Promise<string | undefined> {
 		try {
-			const config = await this.readConfig()
-			return config.modeApiConfigs?.[mode]
+			return await this.lock(async () => {
+				const config = await this.readConfig()
+				return config.modeApiConfigs?.[mode]
+			})
 		} catch (error) {
 			throw new Error(`Failed to get mode config: ${error}`)
 		}
@@ -205,7 +230,9 @@ export class ConfigManager {
 	 * Reset all configuration by deleting the stored config from secrets
 	 */
 	public async resetAllConfigs(): Promise<void> {
-		await this.context.secrets.delete(this.getConfigKey())
+		return await this.lock(async () => {
+			await this.context.secrets.delete(this.getConfigKey())
+		})
 	}
 
 	private async readConfig(): Promise<ApiConfigData> {

+ 63 - 47
src/core/webview/ClineProvider.ts

@@ -1317,6 +1317,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						}
 						break
 					}
+					case "saveApiConfiguration":
+						if (message.text && message.apiConfiguration) {
+							try {
+								await this.configManager.saveConfig(message.text, message.apiConfiguration)
+								const listApiConfig = await this.configManager.listConfig()
+								await this.updateGlobalState("listApiConfigMeta", listApiConfig)
+							} catch (error) {
+								this.outputChannel.appendLine(
+									`Error save api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
+								)
+								vscode.window.showErrorMessage("Failed to save api configuration")
+							}
+						}
+						break
 					case "upsertApiConfiguration":
 						if (message.text && message.apiConfiguration) {
 							try {
@@ -1361,9 +1375,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 								await this.postStateToWebview()
 							} catch (error) {
 								this.outputChannel.appendLine(
-									`Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
+									`Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
 								)
-								vscode.window.showErrorMessage("Failed to create api configuration")
+								vscode.window.showErrorMessage("Failed to rename api configuration")
 							}
 						}
 						break
@@ -1647,51 +1661,53 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			requestyModelInfo,
 			modelTemperature,
 		} = apiConfiguration
-		await this.updateGlobalState("apiProvider", apiProvider)
-		await this.updateGlobalState("apiModelId", apiModelId)
-		await this.storeSecret("apiKey", apiKey)
-		await this.updateGlobalState("glamaModelId", glamaModelId)
-		await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
-		await this.storeSecret("glamaApiKey", glamaApiKey)
-		await this.storeSecret("openRouterApiKey", openRouterApiKey)
-		await this.storeSecret("awsAccessKey", awsAccessKey)
-		await this.storeSecret("awsSecretKey", awsSecretKey)
-		await this.storeSecret("awsSessionToken", awsSessionToken)
-		await this.updateGlobalState("awsRegion", awsRegion)
-		await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
-		await this.updateGlobalState("awsProfile", awsProfile)
-		await this.updateGlobalState("awsUseProfile", awsUseProfile)
-		await this.updateGlobalState("vertexProjectId", vertexProjectId)
-		await this.updateGlobalState("vertexRegion", vertexRegion)
-		await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
-		await this.storeSecret("openAiApiKey", openAiApiKey)
-		await this.updateGlobalState("openAiModelId", openAiModelId)
-		await this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo)
-		await this.updateGlobalState("openAiUseAzure", openAiUseAzure)
-		await this.updateGlobalState("ollamaModelId", ollamaModelId)
-		await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
-		await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
-		await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
-		await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
-		await this.storeSecret("geminiApiKey", geminiApiKey)
-		await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
-		await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
-		await this.updateGlobalState("azureApiVersion", azureApiVersion)
-		await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
-		await this.updateGlobalState("openRouterModelId", openRouterModelId)
-		await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
-		await this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl)
-		await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
-		await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
-		await this.storeSecret("mistralApiKey", mistralApiKey)
-		await this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl)
-		await this.storeSecret("unboundApiKey", unboundApiKey)
-		await this.updateGlobalState("unboundModelId", unboundModelId)
-		await this.updateGlobalState("unboundModelInfo", unboundModelInfo)
-		await this.storeSecret("requestyApiKey", requestyApiKey)
-		await this.updateGlobalState("requestyModelId", requestyModelId)
-		await this.updateGlobalState("requestyModelInfo", requestyModelInfo)
-		await this.updateGlobalState("modelTemperature", modelTemperature)
+		await Promise.all([
+			this.updateGlobalState("apiProvider", apiProvider),
+			this.updateGlobalState("apiModelId", apiModelId),
+			this.storeSecret("apiKey", apiKey),
+			this.updateGlobalState("glamaModelId", glamaModelId),
+			this.updateGlobalState("glamaModelInfo", glamaModelInfo),
+			this.storeSecret("glamaApiKey", glamaApiKey),
+			this.storeSecret("openRouterApiKey", openRouterApiKey),
+			this.storeSecret("awsAccessKey", awsAccessKey),
+			this.storeSecret("awsSecretKey", awsSecretKey),
+			this.storeSecret("awsSessionToken", awsSessionToken),
+			this.updateGlobalState("awsRegion", awsRegion),
+			this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference),
+			this.updateGlobalState("awsProfile", awsProfile),
+			this.updateGlobalState("awsUseProfile", awsUseProfile),
+			this.updateGlobalState("vertexProjectId", vertexProjectId),
+			this.updateGlobalState("vertexRegion", vertexRegion),
+			this.updateGlobalState("openAiBaseUrl", openAiBaseUrl),
+			this.storeSecret("openAiApiKey", openAiApiKey),
+			this.updateGlobalState("openAiModelId", openAiModelId),
+			this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo),
+			this.updateGlobalState("openAiUseAzure", openAiUseAzure),
+			this.updateGlobalState("ollamaModelId", ollamaModelId),
+			this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl),
+			this.updateGlobalState("lmStudioModelId", lmStudioModelId),
+			this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
+			this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
+			this.storeSecret("geminiApiKey", geminiApiKey),
+			this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
+			this.storeSecret("deepSeekApiKey", deepSeekApiKey),
+			this.updateGlobalState("azureApiVersion", azureApiVersion),
+			this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled),
+			this.updateGlobalState("openRouterModelId", openRouterModelId),
+			this.updateGlobalState("openRouterModelInfo", openRouterModelInfo),
+			this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl),
+			this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform),
+			this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector),
+			this.storeSecret("mistralApiKey", mistralApiKey),
+			this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl),
+			this.storeSecret("unboundApiKey", unboundApiKey),
+			this.updateGlobalState("unboundModelId", unboundModelId),
+			this.updateGlobalState("unboundModelInfo", unboundModelInfo),
+			this.storeSecret("requestyApiKey", requestyApiKey),
+			this.updateGlobalState("requestyModelId", requestyModelId),
+			this.updateGlobalState("requestyModelInfo", requestyModelInfo),
+			this.updateGlobalState("modelTemperature", modelTemperature),
+		])
 		if (this.cline) {
 			this.cline.api = buildApiHandler(apiConfiguration)
 		}

+ 33 - 0
src/core/webview/__tests__/ClineProvider.test.ts

@@ -1405,5 +1405,38 @@ describe("ClineProvider", () => {
 			])
 			expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config")
 		})
+
+		test("handles successful saveApiConfiguration", async () => {
+			provider.resolveWebviewView(mockWebviewView)
+			const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+			// Mock ConfigManager methods
+			provider.configManager = {
+				saveConfig: jest.fn().mockResolvedValue(undefined),
+				listConfig: jest
+					.fn()
+					.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
+			} as any
+
+			const testApiConfig = {
+				apiProvider: "anthropic" as const,
+				apiKey: "test-key",
+			}
+
+			// Trigger upsertApiConfiguration
+			await messageHandler({
+				type: "saveApiConfiguration",
+				text: "test-config",
+				apiConfiguration: testApiConfig,
+			})
+
+			// Verify config was saved
+			expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig)
+
+			// Verify state updates
+			expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [
+				{ name: "test-config", id: "test-id", apiProvider: "anthropic" },
+			])
+		})
 	})
 })

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -12,6 +12,7 @@ export interface WebviewMessage {
 	type:
 		| "apiConfiguration"
 		| "currentApiConfigName"
+		| "saveApiConfiguration"
 		| "upsertApiConfiguration"
 		| "deleteApiConfiguration"
 		| "loadApiConfiguration"

+ 5 - 5
webview-ui/package-lock.json

@@ -41,7 +41,7 @@
 				"tailwind-merge": "^2.6.0",
 				"tailwindcss": "^4.0.0",
 				"tailwindcss-animate": "^1.0.7",
-				"vscrui": "^0.2.0"
+				"vscrui": "^0.2.2"
 			},
 			"devDependencies": {
 				"@storybook/addon-essentials": "^8.5.6",
@@ -19841,9 +19841,9 @@
 			}
 		},
 		"node_modules/vscrui": {
-			"version": "0.2.0",
-			"resolved": "https://registry.npmjs.org/vscrui/-/vscrui-0.2.0.tgz",
-			"integrity": "sha512-fvxZM/uIYOMN3fUbE2In+R1VrNj8PKcfAdh+Us2bJaPGuG9ySkR6xkV2aJVqXxWDX77U3v/UQGc5e7URrB52Gw==",
+			"version": "0.2.2",
+			"resolved": "https://registry.npmjs.org/vscrui/-/vscrui-0.2.2.tgz",
+			"integrity": "sha512-buw2OipqUl7GCBq1mxcAjUwoUsslGzVhdaxDPmEx27xzc3QAJJZHtT30QbakgZVJ1Jb3E6kcsguUIFEGxrgkyQ==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
@@ -19851,7 +19851,7 @@
 			},
 			"peerDependencies": {
 				"@types/react": "*",
-				"react": "^17 || ^18"
+				"react": "^17 || ^18 || ^19"
 			}
 		},
 		"node_modules/w3c-hr-time": {

+ 1 - 1
webview-ui/package.json

@@ -48,7 +48,7 @@
 		"tailwind-merge": "^2.6.0",
 		"tailwindcss": "^4.0.0",
 		"tailwindcss-animate": "^1.0.7",
-		"vscrui": "^0.2.0"
+		"vscrui": "^0.2.2"
 	},
 	"devDependencies": {
 		"@storybook/addon-essentials": "^8.5.6",

+ 2 - 4
webview-ui/src/components/settings/ApiConfigManager.tsx

@@ -3,7 +3,7 @@ import { memo, useEffect, useRef, useState } from "react"
 import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
 import { Dropdown } from "vscrui"
 import type { DropdownOption } from "vscrui"
-import { Dialog, DialogContent } from "../ui/dialog"
+import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
 
 interface ApiConfigManagerProps {
 	currentApiConfigName?: string
@@ -298,9 +298,7 @@ const ApiConfigManager = ({
 					}}
 					aria-labelledby="new-profile-title">
 					<DialogContent className="p-4 max-w-sm">
-						<h2 id="new-profile-title" className="text-lg font-semibold mb-4">
-							New Configuration Profile
-						</h2>
+						<DialogTitle>New Configuration Profile</DialogTitle>
 						<button className="absolute right-4 top-4" aria-label="Close dialog" onClick={resetCreateState}>
 							<span className="codicon codicon-close" />
 						</button>

+ 41 - 48
webview-ui/src/components/settings/ApiOptions.tsx

@@ -1,5 +1,5 @@
-import { memo, useCallback, useEffect, useMemo, useState } from "react"
-import { useEvent, useInterval } from "react-use"
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useEvent } from "react-use"
 import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
 import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 import { TemperatureControl } from "./TemperatureControl"
@@ -65,7 +65,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 		return normalizeApiConfiguration(apiConfiguration)
 	}, [apiConfiguration])
 
-	// Poll ollama/lmstudio models
+	const requestLocalModelsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
+	// Pull ollama/lmstudio models
 	const requestLocalModels = useCallback(() => {
 		if (selectedProvider === "ollama") {
 			vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl })
@@ -75,34 +76,29 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 			vscode.postMessage({ type: "requestVsCodeLmModels" })
 		}
 	}, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl])
+	// Debounced model updates, only executed 250ms after the user stops typing
 	useEffect(() => {
-		if (selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm") {
-			requestLocalModels()
+		if (requestLocalModelsTimeoutRef.current) {
+			clearTimeout(requestLocalModelsTimeoutRef.current)
 		}
-	}, [selectedProvider, requestLocalModels])
-	useInterval(
-		requestLocalModels,
-		selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm"
-			? 2000
-			: null,
-	)
+		requestLocalModelsTimeoutRef.current = setTimeout(requestLocalModels, 250)
+		return () => {
+			if (requestLocalModelsTimeoutRef.current) {
+				clearTimeout(requestLocalModelsTimeoutRef.current)
+			}
+		}
+	}, [requestLocalModels])
 	const handleMessage = useCallback((event: MessageEvent) => {
 		const message: ExtensionMessage = event.data
 		if (message.type === "ollamaModels" && Array.isArray(message.ollamaModels)) {
 			const newModels = message.ollamaModels
-			setOllamaModels((prevModels) => {
-				return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
-			})
+			setOllamaModels(newModels)
 		} else if (message.type === "lmStudioModels" && Array.isArray(message.lmStudioModels)) {
 			const newModels = message.lmStudioModels
-			setLmStudioModels((prevModels) => {
-				return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
-			})
+			setLmStudioModels(newModels)
 		} else if (message.type === "vsCodeLmModels" && Array.isArray(message.vsCodeLmModels)) {
 			const newModels = message.vsCodeLmModels
-			setVsCodeLmModels((prevModels) => {
-				return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
-			})
+			setVsCodeLmModels(newModels)
 		}
 	}, [])
 	useEvent("message", handleMessage)
@@ -142,10 +138,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 					id="api-provider"
 					value={selectedProvider}
 					onChange={(value: unknown) => {
-						handleInputChange(
-							"apiProvider",
-							true,
-						)({
+						handleInputChange("apiProvider")({
 							target: {
 								value: (value as DropdownOption).value,
 							},
@@ -178,7 +171,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.apiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("apiKey")}
+						onInput={handleInputChange("apiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
 					</VSCodeTextField>
@@ -203,7 +196,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 							value={apiConfiguration?.anthropicBaseUrl || ""}
 							style={{ width: "100%", marginTop: 3 }}
 							type="url"
-							onBlur={handleInputChange("anthropicBaseUrl")}
+							onInput={handleInputChange("anthropicBaseUrl")}
 							placeholder="Default: https://api.anthropic.com"
 						/>
 					)}
@@ -232,7 +225,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.glamaApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("glamaApiKey")}
+						onInput={handleInputChange("glamaApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>Glama API Key</span>
 					</VSCodeTextField>
@@ -261,7 +254,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.requestyApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("requestyApiKey")}
+						onInput={handleInputChange("requestyApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>Requesty API Key</span>
 					</VSCodeTextField>
@@ -282,7 +275,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.openAiNativeApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("openAiNativeApiKey")}
+						onInput={handleInputChange("openAiNativeApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>OpenAI API Key</span>
 					</VSCodeTextField>
@@ -310,7 +303,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.mistralApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("mistralApiKey")}
+						onInput={handleInputChange("mistralApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>Mistral API Key</span>
 					</VSCodeTextField>
@@ -339,7 +332,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 								value={apiConfiguration?.mistralCodestralUrl || ""}
 								style={{ width: "100%", marginTop: "10px" }}
 								type="url"
-								onBlur={handleInputChange("mistralCodestralUrl")}
+								onInput={handleInputChange("mistralCodestralUrl")}
 								placeholder="Default: https://codestral.mistral.ai">
 								<span style={{ fontWeight: 500 }}>Codestral Base URL (Optional)</span>
 							</VSCodeTextField>
@@ -362,7 +355,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.openRouterApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("openRouterApiKey")}
+						onInput={handleInputChange("openRouterApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
 					</VSCodeTextField>
@@ -406,7 +399,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 									value={apiConfiguration?.openRouterBaseUrl || ""}
 									style={{ width: "100%", marginTop: 3 }}
 									type="url"
-									onBlur={handleInputChange("openRouterBaseUrl")}
+									onInput={handleInputChange("openRouterBaseUrl")}
 									placeholder="Default: https://openrouter.ai/api/v1"
 								/>
 							)}
@@ -444,7 +437,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						<VSCodeTextField
 							value={apiConfiguration?.awsProfile || ""}
 							style={{ width: "100%" }}
-							onBlur={handleInputChange("awsProfile")}
+							onInput={handleInputChange("awsProfile")}
 							placeholder="Enter profile name">
 							<span style={{ fontWeight: 500 }}>AWS Profile Name</span>
 						</VSCodeTextField>
@@ -455,7 +448,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 								value={apiConfiguration?.awsAccessKey || ""}
 								style={{ width: "100%" }}
 								type="password"
-								onBlur={handleInputChange("awsAccessKey")}
+								onInput={handleInputChange("awsAccessKey")}
 								placeholder="Enter Access Key...">
 								<span style={{ fontWeight: 500 }}>AWS Access Key</span>
 							</VSCodeTextField>
@@ -463,7 +456,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 								value={apiConfiguration?.awsSecretKey || ""}
 								style={{ width: "100%" }}
 								type="password"
-								onBlur={handleInputChange("awsSecretKey")}
+								onInput={handleInputChange("awsSecretKey")}
 								placeholder="Enter Secret Key...">
 								<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
 							</VSCodeTextField>
@@ -471,7 +464,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 								value={apiConfiguration?.awsSessionToken || ""}
 								style={{ width: "100%" }}
 								type="password"
-								onBlur={handleInputChange("awsSessionToken")}
+								onInput={handleInputChange("awsSessionToken")}
 								placeholder="Enter Session Token...">
 								<span style={{ fontWeight: 500 }}>AWS Session Token</span>
 							</VSCodeTextField>
@@ -539,7 +532,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 					<VSCodeTextField
 						value={apiConfiguration?.vertexProjectId || ""}
 						style={{ width: "100%" }}
-						onBlur={handleInputChange("vertexProjectId")}
+						onInput={handleInputChange("vertexProjectId")}
 						placeholder="Enter Project ID...">
 						<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
 					</VSCodeTextField>
@@ -597,7 +590,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.geminiApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("geminiApiKey")}
+						onInput={handleInputChange("geminiApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>Gemini API Key</span>
 					</VSCodeTextField>
@@ -625,7 +618,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.openAiBaseUrl || ""}
 						style={{ width: "100%" }}
 						type="url"
-						onBlur={handleInputChange("openAiBaseUrl")}
+						onInput={handleInputChange("openAiBaseUrl")}
 						placeholder={"Enter base URL..."}>
 						<span style={{ fontWeight: 500 }}>Base URL</span>
 					</VSCodeTextField>
@@ -633,7 +626,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.openAiApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("openAiApiKey")}
+						onInput={handleInputChange("openAiApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>API Key</span>
 					</VSCodeTextField>
@@ -676,7 +669,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						<VSCodeTextField
 							value={apiConfiguration?.azureApiVersion || ""}
 							style={{ width: "100%", marginTop: 3 }}
-							onBlur={handleInputChange("azureApiVersion")}
+							onInput={handleInputChange("azureApiVersion")}
 							placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
 						/>
 					)}
@@ -1126,14 +1119,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.lmStudioBaseUrl || ""}
 						style={{ width: "100%" }}
 						type="url"
-						onBlur={handleInputChange("lmStudioBaseUrl")}
+						onInput={handleInputChange("lmStudioBaseUrl")}
 						placeholder={"Default: http://localhost:1234"}>
 						<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
 					</VSCodeTextField>
 					<VSCodeTextField
 						value={apiConfiguration?.lmStudioModelId || ""}
 						style={{ width: "100%" }}
-						onBlur={handleInputChange("lmStudioModelId")}
+						onInput={handleInputChange("lmStudioModelId")}
 						placeholder={"e.g. meta-llama-3.1-8b-instruct"}>
 						<span style={{ fontWeight: 500 }}>Model ID</span>
 					</VSCodeTextField>
@@ -1195,7 +1188,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.deepSeekApiKey || ""}
 						style={{ width: "100%" }}
 						type="password"
-						onBlur={handleInputChange("deepSeekApiKey")}
+						onInput={handleInputChange("deepSeekApiKey")}
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>DeepSeek API Key</span>
 					</VSCodeTextField>
@@ -1285,14 +1278,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						value={apiConfiguration?.ollamaBaseUrl || ""}
 						style={{ width: "100%" }}
 						type="url"
-						onBlur={handleInputChange("ollamaBaseUrl")}
+						onInput={handleInputChange("ollamaBaseUrl")}
 						placeholder={"Default: http://localhost:11434"}>
 						<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
 					</VSCodeTextField>
 					<VSCodeTextField
 						value={apiConfiguration?.ollamaModelId || ""}
 						style={{ width: "100%" }}
-						onBlur={handleInputChange("ollamaModelId")}
+						onInput={handleInputChange("ollamaModelId")}
 						placeholder={"e.g. llama3.1"}>
 						<span style={{ fontWeight: 500 }}>Model ID</span>
 					</VSCodeTextField>

+ 6 - 11
webview-ui/src/components/settings/SettingsView.tsx

@@ -70,23 +70,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 	const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
 	const [commandInput, setCommandInput] = useState("")
 
-	const handleSubmit = async () => {
-		// Focus the active element's parent to trigger blur
-		document.activeElement?.parentElement?.focus()
-
-		// Small delay to let blur events complete
-		await new Promise((resolve) => setTimeout(resolve, 50))
-
+	const handleSubmit = () => {
 		const apiValidationResult = validateApiConfiguration(apiConfiguration)
 		const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
 
 		setApiErrorMessage(apiValidationResult)
 		setModelIdErrorMessage(modelIdValidationResult)
 		if (!apiValidationResult && !modelIdValidationResult) {
-			vscode.postMessage({
-				type: "apiConfiguration",
-				apiConfiguration,
-			})
 			vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
 			vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
 			vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
@@ -201,6 +191,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 							currentApiConfigName={currentApiConfigName}
 							listApiConfigMeta={listApiConfigMeta}
 							onSelectConfig={(configName: string) => {
+								vscode.postMessage({
+									type: "saveApiConfiguration",
+									text: currentApiConfigName,
+									apiConfiguration,
+								})
 								vscode.postMessage({
 									type: "loadApiConfiguration",
 									text: configName,

+ 1 - 0
webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx

@@ -41,6 +41,7 @@ jest.mock("@/components/ui/dialog", () => ({
 		</div>
 	),
 	DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
+	DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
 }))
 
 describe("ApiConfigManager", () => {

+ 21 - 26
webview-ui/src/components/settings/__tests__/SettingsView.test.tsx

@@ -1,4 +1,5 @@
-import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import React from "react"
+import { render, screen, fireEvent } from "@testing-library/react"
 import SettingsView from "../SettingsView"
 import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext"
 import { vscode } from "../../../utils/vscode"
@@ -126,7 +127,7 @@ describe("SettingsView - Sound Settings", () => {
 		expect(screen.queryByRole("slider", { name: /volume/i })).not.toBeInTheDocument()
 	})
 
-	it("toggles sound setting and sends message to VSCode", async () => {
+	it("toggles sound setting and sends message to VSCode", () => {
 		renderSettingsView()
 
 		const soundCheckbox = screen.getByRole("checkbox", {
@@ -141,14 +142,12 @@ describe("SettingsView - Sound Settings", () => {
 		const doneButton = screen.getByText("Done")
 		fireEvent.click(doneButton)
 
-		await waitFor(() => {
-			expect(vscode.postMessage).toHaveBeenCalledWith(
-				expect.objectContaining({
-					type: "soundEnabled",
-					bool: true,
-				}),
-			)
-		})
+		expect(vscode.postMessage).toHaveBeenCalledWith(
+			expect.objectContaining({
+				type: "soundEnabled",
+				bool: true,
+			}),
+		)
 	})
 
 	it("shows volume slider when sound is enabled", () => {
@@ -166,7 +165,7 @@ describe("SettingsView - Sound Settings", () => {
 		expect(volumeSlider).toHaveValue("0.5")
 	})
 
-	it("updates volume and sends message to VSCode when slider changes", async () => {
+	it("updates volume and sends message to VSCode when slider changes", () => {
 		renderSettingsView()
 
 		// Enable sound
@@ -184,11 +183,9 @@ describe("SettingsView - Sound Settings", () => {
 		fireEvent.click(doneButton)
 
 		// Verify message sent to VSCode
-		await waitFor(() => {
-			expect(vscode.postMessage).toHaveBeenCalledWith({
-				type: "soundVolume",
-				value: 0.75,
-			})
+		expect(vscode.postMessage).toHaveBeenCalledWith({
+			type: "soundVolume",
+			value: 0.75,
 		})
 	})
 })
@@ -305,7 +302,7 @@ describe("SettingsView - Allowed Commands", () => {
 		expect(commands).toHaveLength(1)
 	})
 
-	it("saves allowed commands when clicking Done", async () => {
+	it("saves allowed commands when clicking Done", () => {
 		const { onDone } = renderSettingsView()
 
 		// Enable always allow execute
@@ -325,14 +322,12 @@ describe("SettingsView - Allowed Commands", () => {
 		fireEvent.click(doneButton)
 
 		// Verify VSCode messages were sent
-		await waitFor(() => {
-			expect(vscode.postMessage).toHaveBeenCalledWith(
-				expect.objectContaining({
-					type: "allowedCommands",
-					commands: ["npm test"],
-				}),
-			)
-			expect(onDone).toHaveBeenCalled()
-		})
+		expect(vscode.postMessage).toHaveBeenCalledWith(
+			expect.objectContaining({
+				type: "allowedCommands",
+				commands: ["npm test"],
+			}),
+		)
+		expect(onDone).toHaveBeenCalled()
 	})
 })

+ 7 - 9
webview-ui/src/components/welcome/WelcomeView.tsx

@@ -6,24 +6,22 @@ import { vscode } from "../../utils/vscode"
 import ApiOptions from "../settings/ApiOptions"
 
 const WelcomeView = () => {
-	const { apiConfiguration } = useExtensionState()
+	const { apiConfiguration, currentApiConfigName } = useExtensionState()
 
 	const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
 
-	const handleSubmit = async () => {
-		// Focus the active element's parent to trigger blur
-		document.activeElement?.parentElement?.focus()
-
-		// Small delay to let blur events complete
-		await new Promise((resolve) => setTimeout(resolve, 50))
-
+	const handleSubmit = () => {
 		const error = validateApiConfiguration(apiConfiguration)
 		if (error) {
 			setErrorMessage(error)
 			return
 		}
 		setErrorMessage(undefined)
-		vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
+		vscode.postMessage({
+			type: "upsertApiConfiguration",
+			text: currentApiConfigName,
+			apiConfiguration,
+		})
 	}
 
 	return (

+ 4 - 18
webview-ui/src/context/ExtensionStateContext.tsx

@@ -162,26 +162,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 	const handleInputChange = useCallback(
 		// Returns a function that handles an input change event for a specific API configuration field.
 		// The optional "softUpdate" flag determines whether to immediately update local state or send an external update.
-		(field: keyof ApiConfiguration, softUpdate?: boolean) => (event: any) => {
+		(field: keyof ApiConfiguration) => (event: any) => {
 			// Use the functional form of setState to ensure the latest state is used in the update logic.
 			setState((currentState) => {
-				if (softUpdate) {
-					// Return a new state object with the updated apiConfiguration.
-					// This will trigger a re-render with the new configuration value.
-					return {
-						...currentState,
-						apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
-					}
-				} else {
-					// For non-soft updates, send a message to the VS Code extension with the updated config.
-					// This side effect communicates the change without updating local React state.
-					vscode.postMessage({
-						type: "upsertApiConfiguration",
-						text: currentState.currentApiConfigName,
-						apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
-					})
-					// Return the unchanged state as no local state update is intended in this branch.
-					return currentState
+				return {
+					...currentState,
+					apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
 				}
 			})
 		},