diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 467eeb3d264..dd4262ee2e8 100644 --- a/src/core/config/ConfigManager.ts +++ b/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(cb: () => Promise) { + const next = this._lock.then(cb) + this._lock = next.catch(() => {}) as Promise + return next + } /** * Initialize config if it doesn't exist */ async initConfig(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { - await this.context.secrets.delete(this.getConfigKey()) + return await this.lock(async () => { + await this.context.secrets.delete(this.getConfigKey()) + }) } private async readConfig(): Promise { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4375cf4da18..fd7798bcadc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/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) } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 0f6612caa91..0a8f73308f5 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/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" }, + ]) + }) }) }) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index aadbe0167cd..4dde38dfcde 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -12,6 +12,7 @@ export interface WebviewMessage { type: | "apiConfiguration" | "currentApiConfigName" + | "saveApiConfiguration" | "upsertApiConfiguration" | "deleteApiConfiguration" | "loadApiConfiguration" diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index b3dc15147fc..76a1a288a63 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -34,7 +34,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.2", @@ -16511,9 +16511,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", @@ -16521,7 +16521,7 @@ }, "peerDependencies": { "@types/react": "*", - "react": "^17 || ^18" + "react": "^17 || ^18 || ^19" } }, "node_modules/w3c-hr-time": { diff --git a/webview-ui/package.json b/webview-ui/package.json index 59ea5693b6e..10c412a2ece 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.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.2", diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 62aae276421..15c9a2b0c0c 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/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"> -

- New Configuration Profile -

+ New Configuration Profile diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 66459eba490..c4214939d52 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/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(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..."> Anthropic API Key @@ -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..."> Glama API Key @@ -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..."> Requesty API Key @@ -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..."> OpenAI API Key @@ -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..."> Mistral API Key @@ -341,7 +334,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"> Codestral Base URL (Optional) @@ -364,7 +357,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..."> OpenRouter API Key @@ -408,7 +401,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" /> )} @@ -446,7 +439,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A AWS Profile Name @@ -457,7 +450,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..."> AWS Access Key @@ -465,7 +458,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..."> AWS Secret Key @@ -473,7 +466,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..."> AWS Session Token @@ -541,7 +534,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A Google Cloud Project ID @@ -599,7 +592,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..."> Gemini API Key @@ -627,7 +620,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..."}> Base URL @@ -635,7 +628,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..."> API Key @@ -678,7 +671,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A )} @@ -1128,14 +1121,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"}> Base URL (optional) Model ID @@ -1197,7 +1190,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..."> DeepSeek API Key @@ -1287,14 +1280,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"}> Base URL (optional) Model ID diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index b866561adff..bef57d1f5ae 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -70,23 +70,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { const [modelIdErrorMessage, setModelIdErrorMessage] = useState(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, diff --git a/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx b/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx index 24e62215ece..92a6a1cd036 100644 --- a/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx @@ -41,6 +41,7 @@ jest.mock("@/components/ui/dialog", () => ({ ), DialogContent: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>
{children}
, })) describe("ApiConfigManager", () => { diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index e9242f7be64..37654326107 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/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() }) }) diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index 6513e4b10be..54712c0e6a5 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/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(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 ( diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d7ccc7d56cf..8f4fda94c3d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/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 }, } }) },