diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index e75113f923..3888efaa8a 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -34,7 +34,6 @@ import { requestyDefaultModelInfo, } from "../../../../src/shared/api" import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" -import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" import VSCodeButtonLink from "../common/VSCodeButtonLink" import { OpenRouterModelPicker } from "./OpenRouterModelPicker" @@ -46,13 +45,22 @@ import { DROPDOWN_Z_INDEX } from "./styles" import { RequestyModelPicker } from "./RequestyModelPicker" interface ApiOptionsProps { + uriScheme: string | undefined + apiConfiguration: ApiConfiguration | undefined + setApiConfigurationField: (field: K, value: ApiConfiguration[K]) => void apiErrorMessage?: string modelIdErrorMessage?: string fromWelcomeView?: boolean } -const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: ApiOptionsProps) => { - const { apiConfiguration, uriScheme, handleInputChange } = useExtensionState() +const ApiOptions = ({ + uriScheme, + apiConfiguration, + setApiConfigurationField, + apiErrorMessage, + modelIdErrorMessage, + fromWelcomeView, +}: ApiOptionsProps) => { const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) const [vsCodeLmModels, setVsCodeLmModels] = useState([]) @@ -61,6 +69,21 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + const inputEventTransform = (event: E) => (event as { target: HTMLInputElement })?.target?.value as any + const noTransform = (value: T) => value + const dropdownEventTransform = (event: DropdownOption | string | undefined) => + (typeof event == "string" ? event : event?.value) as T + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ApiConfiguration[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) @@ -115,12 +138,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A { - handleInputChange("apiModelId")({ - target: { - value: (value as DropdownOption).value, - }, - }) + onChange={(value) => { + setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value) }} style={{ width: "100%" }} options={options} @@ -137,13 +156,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A { - handleInputChange("apiProvider")({ - target: { - value: (value as DropdownOption).value, - }, - }) - }} + onChange={handleInputChange("apiProvider", dropdownEventTransform)} style={{ minWidth: 130, position: "relative", zIndex: DROPDOWN_Z_INDEX + 1 }} options={[ { value: "openrouter", label: "OpenRouter" }, @@ -181,11 +194,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A onChange={(checked: boolean) => { setAnthropicBaseUrlSelected(checked) if (!checked) { - handleInputChange("anthropicBaseUrl")({ - target: { - value: "", - }, - }) + setApiConfigurationField("anthropicBaseUrl", "") } }}> Use custom base URL @@ -384,11 +393,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A onChange={(checked: boolean) => { setOpenRouterBaseUrlSelected(checked) if (!checked) { - handleInputChange("openRouterBaseUrl")({ - target: { - value: "", - }, - }) + setApiConfigurationField("openRouterBaseUrl", "") } }}> Use custom base URL @@ -405,11 +410,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A )} { - handleInputChange("openRouterUseMiddleOutTransform")({ - target: { value: checked }, - }) - }}> + onChange={handleInputChange("openRouterUseMiddleOutTransform", noTransform)}> Compress prompts and message chains to the context size ( OpenRouter Transforms) @@ -422,13 +423,10 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
{ - const value = (e.target as HTMLInputElement)?.value - const useProfile = value === "profile" - handleInputChange("awsUseProfile")({ - target: { value: useProfile }, - }) - }}> + onChange={handleInputChange( + "awsUseProfile", + (e) => (e.target as HTMLInputElement).value === "profile", + )}> AWS Credentials AWS Profile @@ -479,11 +477,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A value={apiConfiguration?.awsRegion || ""} style={{ width: "100%" }} onChange={(value: unknown) => { - handleInputChange("awsRegion")({ - target: { - value: (value as DropdownOption).value, - }, - }) + handleInputChange("awsRegion", dropdownEventTransform) }} options={[ { value: "", label: "Select a region..." }, @@ -507,11 +501,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
{ - handleInputChange("awsUseCrossRegionInference")({ - target: { value: checked }, - }) - }}> + onChange={handleInputChange("awsUseCrossRegionInference", noTransform)}> Use cross-region inference

{ - handleInputChange("vertexRegion")({ - target: { - value: (value as DropdownOption).value, - }, - }) - }} + onChange={handleInputChange("vertexRegion", dropdownEventTransform)} options={[ { value: "", label: "Select a region..." }, { value: "us-east5", label: "us-east5" }, @@ -634,21 +618,13 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A

{ - handleInputChange("openAiStreamingEnabled")({ - target: { value: checked }, - }) - }}> + onChange={handleInputChange("openAiStreamingEnabled", noTransform)}> Enable streaming
{ - handleInputChange("openAiUseAzure")({ - target: { value: checked }, - }) - }}> + onChange={handleInputChange("openAiUseAzure", noTransform)}> Use Azure { setAzureApiVersionSelected(checked) if (!checked) { - handleInputChange("azureApiVersion")({ - target: { - value: "", - }, - }) + setApiConfigurationField("azureApiVersion", "") } }}> Set Azure API version @@ -686,9 +658,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A { iconName: "refresh", onClick: () => - handleInputChange("openAiCustomModelInfo")({ - target: { value: openAiModelInfoSaneDefaults }, - }), + setApiConfigurationField("openAiCustomModelInfo", openAiModelInfoSaneDefaults), }, ]}>
{ - const value = parseInt(e.target.value) - handleInputChange("openAiCustomModelInfo")({ - target: { - value: { - ...(apiConfiguration?.openAiCustomModelInfo || - openAiModelInfoSaneDefaults), - maxTokens: isNaN(value) ? undefined : value, - }, - }, - }) - }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = parseInt((e.target as HTMLInputElement).value) + return { + ...(apiConfiguration?.openAiCustomModelInfo || + openAiModelInfoSaneDefaults), + maxTokens: isNaN(value) ? undefined : value, + } + })} placeholder="e.g. 4096"> Max Output Tokens @@ -796,23 +762,17 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A })(), }} title="Total number of tokens (input + output) the model can process in a single request" - onChange={(e: any) => { - const parsed = parseInt(e.target.value) - handleInputChange("openAiCustomModelInfo")({ - target: { - value: { - ...(apiConfiguration?.openAiCustomModelInfo || - openAiModelInfoSaneDefaults), - contextWindow: - e.target.value === "" - ? undefined - : isNaN(parsed) - ? openAiModelInfoSaneDefaults.contextWindow - : parsed, - }, - }, - }) - }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseInt(value) + return { + ...(apiConfiguration?.openAiCustomModelInfo || + openAiModelInfoSaneDefaults), + contextWindow: isNaN(parsed) + ? openAiModelInfoSaneDefaults.contextWindow + : parsed, + } + })} placeholder="e.g. 128000"> Context Window Size @@ -861,17 +821,16 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A apiConfiguration?.openAiCustomModelInfo?.supportsImages ?? openAiModelInfoSaneDefaults.supportsImages } - onChange={(checked: boolean) => { - handleInputChange("openAiCustomModelInfo")({ - target: { - value: { - ...(apiConfiguration?.openAiCustomModelInfo || - openAiModelInfoSaneDefaults), - supportsImages: checked, - }, - }, - }) - }}> + onChange={handleInputChange( + "openAiCustomModelInfo", + (checked) => { + return { + ...(apiConfiguration?.openAiCustomModelInfo || + openAiModelInfoSaneDefaults), + supportsImages: checked, + } + }, + )}> Image Support { - handleInputChange("openAiCustomModelInfo")({ - target: { - value: { - ...(apiConfiguration?.openAiCustomModelInfo || - openAiModelInfoSaneDefaults), - supportsComputerUse: checked, - }, - }, - }) - }}> + onChange={handleInputChange( + "openAiCustomModelInfo", + (checked) => { + return { + ...(apiConfiguration?.openAiCustomModelInfo || + openAiModelInfoSaneDefaults), + supportsComputerUse: checked, + } + }, + )}> Computer Use { - const parsed = parseFloat(e.target.value) - handleInputChange("openAiCustomModelInfo")({ - target: { - value: { - ...(apiConfiguration?.openAiCustomModelInfo ?? - openAiModelInfoSaneDefaults), - inputPrice: - e.target.value === "" - ? undefined - : isNaN(parsed) - ? openAiModelInfoSaneDefaults.inputPrice - : parsed, - }, - }, - }) - }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseInt(value) + return { + ...(apiConfiguration?.openAiCustomModelInfo ?? + openAiModelInfoSaneDefaults), + inputPrice: isNaN(parsed) + ? openAiModelInfoSaneDefaults.inputPrice + : parsed, + } + })} placeholder="e.g. 0.0001">
Input Price @@ -1055,23 +1007,17 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A : "var(--vscode-errorForeground)" })(), }} - onChange={(e: any) => { - const parsed = parseFloat(e.target.value) - handleInputChange("openAiCustomModelInfo")({ - target: { - value: { - ...(apiConfiguration?.openAiCustomModelInfo || - openAiModelInfoSaneDefaults), - outputPrice: - e.target.value === "" - ? undefined - : isNaN(parsed) - ? openAiModelInfoSaneDefaults.outputPrice - : parsed, - }, - }, - }) - }} + onChange={handleInputChange("openAiCustomModelInfo", (e) => { + const value = (e.target as HTMLInputElement).value + const parsed = parseInt(value) + return { + ...(apiConfiguration?.openAiCustomModelInfo || + openAiModelInfoSaneDefaults), + outputPrice: isNaN(parsed) + ? openAiModelInfoSaneDefaults.outputPrice + : parsed, + } + })} placeholder="e.g. 0.0002">
Output Price @@ -1137,15 +1083,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A ? apiConfiguration?.lmStudioModelId : "" } - onChange={(e) => { - const value = (e.target as HTMLInputElement)?.value - // need to check value first since radio group returns empty string sometimes - if (value) { - handleInputChange("lmStudioModelId")({ - target: { value }, - }) - } - }}> + onChange={handleInputChange("lmStudioModelId")}> {lmStudioModels.map((model) => ( { - const valueStr = (value as DropdownOption)?.value - if (!valueStr) { - return - } + onChange={handleInputChange("vsCodeLmModelSelector", (e) => { + const valueStr = (e as DropdownOption)?.value const [vendor, family] = valueStr.split("/") - handleInputChange("vsCodeLmModelSelector")({ - target: { - value: { vendor, family }, - }, - }) - }} + return { vendor, family } + })} style={{ width: "100%" }} options={[ { value: "", label: "Select a model..." }, @@ -1296,15 +1227,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A ? apiConfiguration?.ollamaModelId : "" } - onChange={(e) => { - const value = (e.target as HTMLInputElement)?.value - // need to check value first since radio group returns empty string sometimes - if (value) { - handleInputChange("ollamaModelId")({ - target: { value }, - }) - } - }}> + onChange={handleInputChange("ollamaModelId")}> {ollamaModels.map((model) => ( { - handleInputChange("modelTemperature")({ - target: { value }, - }) - }} + onChange={handleInputChange("modelTemperature", noTransform)} maxValue={2} />
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index bef57d1f5a..fc5818abe9 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -1,6 +1,6 @@ import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { memo, useEffect, useState } from "react" -import { useExtensionState } from "../../context/ExtensionStateContext" +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { ExtensionStateContextType, useExtensionState } from "../../context/ExtensionStateContext" import { validateApiConfiguration, validateModelId } from "../../utils/validate" import { vscode } from "../../utils/vscode" import ApiOptions from "./ApiOptions" @@ -9,70 +9,110 @@ import { EXPERIMENT_IDS, experimentConfigsMap } from "../../../../src/shared/exp import ApiConfigManager from "./ApiConfigManager" import { Dropdown } from "vscrui" import type { DropdownOption } from "vscrui" +import { ApiConfiguration } from "../../../../src/shared/api" type SettingsViewProps = { onDone: () => void } const SettingsView = ({ onDone }: SettingsViewProps) => { - const { - apiConfiguration, - version, - alwaysAllowReadOnly, - setAlwaysAllowReadOnly, - alwaysAllowWrite, - setAlwaysAllowWrite, - alwaysAllowExecute, - setAlwaysAllowExecute, - alwaysAllowBrowser, - setAlwaysAllowBrowser, - alwaysAllowMcp, - setAlwaysAllowMcp, - soundEnabled, - setSoundEnabled, - soundVolume, - setSoundVolume, - diffEnabled, - setDiffEnabled, - checkpointsEnabled, - setCheckpointsEnabled, - browserViewportSize, - setBrowserViewportSize, - openRouterModels, - glamaModels, - setAllowedCommands, - allowedCommands, - fuzzyMatchThreshold, - setFuzzyMatchThreshold, - writeDelayMs, - setWriteDelayMs, - screenshotQuality, - setScreenshotQuality, - terminalOutputLineLimit, - setTerminalOutputLineLimit, - mcpEnabled, - alwaysApproveResubmit, - setAlwaysApproveResubmit, - requestDelaySeconds, - setRequestDelaySeconds, - rateLimitSeconds, - setRateLimitSeconds, - currentApiConfigName, - listApiConfigMeta, - experiments, - setExperimentEnabled, - alwaysAllowModeSwitch, - setAlwaysAllowModeSwitch, - maxOpenTabsContext, - setMaxOpenTabsContext, - } = useExtensionState() + const extensionState = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) const [commandInput, setCommandInput] = useState("") + const prevApiConfigName = useRef(extensionState.currentApiConfigName) + + // TODO: Reduce WebviewMessage/ExtensionState complexity + const [cachedState, setCachedState] = useState(extensionState) + const [isChangeDetected, setChangeDetected] = useState(false) + const { currentApiConfigName } = extensionState + const { + apiConfiguration, + alwaysAllowReadOnly, + allowedCommands, + alwaysAllowBrowser, + alwaysAllowExecute, + alwaysAllowMcp, + alwaysAllowModeSwitch, + alwaysAllowWrite, + alwaysApproveResubmit, + browserViewportSize, + checkpointsEnabled, + diffEnabled, + experiments, + fuzzyMatchThreshold, + maxOpenTabsContext, + mcpEnabled, + rateLimitSeconds, + requestDelaySeconds, + screenshotQuality, + soundEnabled, + soundVolume, + terminalOutputLineLimit, + writeDelayMs, + } = cachedState + + useEffect(() => { + // Update only when currentApiConfigName is changed + // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration + if (prevApiConfigName.current === currentApiConfigName) { + return + } + setCachedState((prevCachedState) => ({ + ...prevCachedState, + ...extensionState, + })) + prevApiConfigName.current = currentApiConfigName + setChangeDetected(false) + }, [currentApiConfigName, extensionState, isChangeDetected]) + + const setCachedStateField = useCallback( + (field: K, value: ExtensionStateContextType[K]) => + setCachedState((prevState) => { + if (prevState[field] === value) { + return prevState + } + setChangeDetected(true) + return { + ...prevState, + [field]: value, + } + }), + [], + ) + + const setApiConfigurationField = useCallback( + (field: K, value: ApiConfiguration[K]) => { + setCachedState((prevState) => { + if (prevState.apiConfiguration?.[field] === value) { + return prevState + } + setChangeDetected(true) + return { + ...prevState, + apiConfiguration: { + ...apiConfiguration, + [field]: value, + }, + } + }) + }, + [apiConfiguration], + ) + + const setExperimentEnabled = useCallback( + (id: string, enabled: boolean) => + setCachedStateField("experiments", { ...cachedState.experiments, [id]: enabled }), + [cachedState.experiments, setCachedStateField], + ) const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) - const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) + const modelIdValidationResult = validateModelId( + apiConfiguration, + extensionState.glamaModels, + extensionState.openRouterModels, + ) setApiErrorMessage(apiValidationResult) setModelIdErrorMessage(modelIdValidationResult) @@ -98,19 +138,19 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "rateLimitSeconds", value: rateLimitSeconds }) vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext }) vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) + vscode.postMessage({ + type: "updateExperimental", + values: experiments, + }) + vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) + vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration, }) - - vscode.postMessage({ - type: "updateExperimental", - values: experiments, - }) - - vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) - onDone() + // onDone() + setChangeDetected(false) } } @@ -122,10 +162,14 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { // Initial validation on mount useEffect(() => { const apiValidationResult = validateApiConfiguration(apiConfiguration) - const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) + const modelIdValidationResult = validateModelId( + apiConfiguration, + extensionState.glamaModels, + extensionState.openRouterModels, + ) setApiErrorMessage(apiValidationResult) setModelIdErrorMessage(modelIdValidationResult) - }, [apiConfiguration, glamaModels, openRouterModels]) + }, [apiConfiguration, extensionState.glamaModels, extensionState.openRouterModels]) const handleResetState = () => { vscode.postMessage({ type: "resetState" }) @@ -135,7 +179,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { const currentCommands = allowedCommands ?? [] if (commandInput && !currentCommands.includes(commandInput)) { const newCommands = [...currentCommands, commandInput] - setAllowedCommands(newCommands) + setCachedStateField("allowedCommands", newCommands) setCommandInput("") vscode.postMessage({ type: "allowedCommands", @@ -180,7 +224,26 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { paddingRight: 17, }}>

Settings

- Done +
+ + Save + + + Done + +
@@ -189,13 +252,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
{ - vscode.postMessage({ - type: "saveApiConfiguration", - text: currentApiConfigName, - apiConfiguration, - }) vscode.postMessage({ type: "loadApiConfiguration", text: configName, @@ -213,6 +271,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { values: { oldName, newName }, apiConfiguration, }) + prevApiConfigName.current = newName }} onUpsertConfig={(configName: string) => { vscode.postMessage({ @@ -222,7 +281,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { }) }} /> - +
@@ -237,7 +302,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysAllowReadOnly(e.target.checked)}> + onChange={(e: any) => setCachedStateField("alwaysAllowReadOnly", e.target.checked)}> Always approve read-only operations

{

setAlwaysAllowWrite(e.target.checked)}> + onChange={(e: any) => setCachedStateField("alwaysAllowWrite", e.target.checked)}> Always approve write operations

@@ -274,7 +339,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { max="5000" step="100" value={writeDelayMs} - onChange={(e) => setWriteDelayMs(parseInt(e.target.value))} + onChange={(e) => setCachedStateField("writeDelayMs", parseInt(e.target.value))} style={{ flex: 1, accentColor: "var(--vscode-button-background)", @@ -298,7 +363,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

setAlwaysAllowBrowser(e.target.checked)}> + onChange={(e: any) => setCachedStateField("alwaysAllowBrowser", e.target.checked)}> Always approve browser actions

@@ -311,7 +376,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

setAlwaysApproveResubmit(e.target.checked)}> + onChange={(e: any) => setCachedStateField("alwaysApproveResubmit", e.target.checked)}> Always retry failed API requests

@@ -331,7 +396,9 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { max="100" step="1" value={requestDelaySeconds} - onChange={(e) => setRequestDelaySeconds(parseInt(e.target.value))} + onChange={(e) => + setCachedStateField("requestDelaySeconds", parseInt(e.target.value)) + } style={{ flex: 1, accentColor: "var(--vscode-button-background)", @@ -355,7 +422,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

setAlwaysAllowMcp(e.target.checked)}> + onChange={(e: any) => setCachedStateField("alwaysAllowMcp", e.target.checked)}> Always approve MCP tools

@@ -367,7 +434,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

setAlwaysAllowModeSwitch(e.target.checked)}> + onChange={(e: any) => setCachedStateField("alwaysAllowModeSwitch", e.target.checked)}> Always approve mode switching & task creation

@@ -379,7 +446,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

setAlwaysAllowExecute(e.target.checked)}> + onChange={(e: any) => setCachedStateField("alwaysAllowExecute", e.target.checked)}> Always approve allowed execute operations

@@ -458,7 +525,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { const newCommands = (allowedCommands ?? []).filter( (_, i) => i !== index, ) - setAllowedCommands(newCommands) + setCachedStateField("allowedCommands", newCommands) vscode.postMessage({ type: "allowedCommands", commands: newCommands, @@ -482,7 +549,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { { - setBrowserViewportSize((value as DropdownOption).value) + setCachedStateField("browserViewportSize", (value as DropdownOption).value) }} style={{ width: "100%" }} options={[ @@ -514,7 +581,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { max="100" step="1" value={screenshotQuality ?? 75} - onChange={(e) => setScreenshotQuality(parseInt(e.target.value))} + onChange={(e) => setCachedStateField("screenshotQuality", parseInt(e.target.value))} style={{ ...sliderStyle, }} @@ -537,7 +604,9 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

Notification Settings

- setSoundEnabled(e.target.checked)}> + setCachedStateField("soundEnabled", e.target.checked)}> Enable sound effects

{ max="1" step="0.01" value={soundVolume ?? 0.5} - onChange={(e) => setSoundVolume(parseFloat(e.target.value))} + onChange={(e) => setCachedStateField("soundVolume", parseFloat(e.target.value))} style={{ flexGrow: 1, accentColor: "var(--vscode-button-background)", @@ -592,7 +661,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { max="60" step="1" value={rateLimitSeconds} - onChange={(e) => setRateLimitSeconds(parseInt(e.target.value))} + onChange={(e) => setCachedStateField("rateLimitSeconds", parseInt(e.target.value))} style={{ ...sliderStyle }} /> {rateLimitSeconds}s @@ -612,7 +681,9 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { max="5000" step="100" value={terminalOutputLineLimit ?? 500} - onChange={(e) => setTerminalOutputLineLimit(parseInt(e.target.value))} + onChange={(e) => + setCachedStateField("terminalOutputLineLimit", parseInt(e.target.value)) + } style={{ ...sliderStyle }} /> {terminalOutputLineLimit ?? 500} @@ -634,7 +705,9 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { max="500" step="1" value={maxOpenTabsContext ?? 20} - onChange={(e) => setMaxOpenTabsContext(parseInt(e.target.value))} + onChange={(e) => + setCachedStateField("maxOpenTabsContext", parseInt(e.target.value)) + } style={{ ...sliderStyle }} /> {maxOpenTabsContext ?? 20} @@ -650,7 +723,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { { - setDiffEnabled(e.target.checked) + setCachedStateField("diffEnabled", e.target.checked) if (!e.target.checked) { // Reset experimental strategy when diffs are disabled setExperimentEnabled(EXPERIMENT_IDS.DIFF_STRATEGY, false) @@ -689,7 +762,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { step="0.005" value={fuzzyMatchThreshold ?? 1.0} onChange={(e) => { - setFuzzyMatchThreshold(parseFloat(e.target.value)) + setCachedStateField("fuzzyMatchThreshold", parseFloat(e.target.value)) }} style={{ ...sliderStyle, @@ -727,7 +800,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { { - setCheckpointsEnabled(e.target.checked) + setCachedStateField("checkpointsEnabled", e.target.checked) }}> Enable experimental checkpoints @@ -783,7 +856,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

- v{version} + v{extensionState.version}

{ const renderApiOptions = (props = {}) => { render( - + {}} + {...props} + /> , ) } diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index 3765432610..99bc0a87c2 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx @@ -1,4 +1,3 @@ -import React from "react" import { render, screen, fireEvent } from "@testing-library/react" import SettingsView from "../SettingsView" import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext" @@ -138,9 +137,9 @@ describe("SettingsView - Sound Settings", () => { fireEvent.click(soundCheckbox) expect(soundCheckbox).toBeChecked() - // Click Done to save settings - const doneButton = screen.getByText("Done") - fireEvent.click(doneButton) + // Click Save to save settings + const saveButton = screen.getByText("Save") + fireEvent.click(saveButton) expect(vscode.postMessage).toHaveBeenCalledWith( expect.objectContaining({ @@ -178,9 +177,9 @@ describe("SettingsView - Sound Settings", () => { const volumeSlider = screen.getByRole("slider", { name: /volume/i }) fireEvent.change(volumeSlider, { target: { value: "0.75" } }) - // Click Done to save settings - const doneButton = screen.getByText("Done") - fireEvent.click(doneButton) + // Click Save to save settings + const saveButton = screen.getByText("Save") + fireEvent.click(saveButton) // Verify message sent to VSCode expect(vscode.postMessage).toHaveBeenCalledWith({ @@ -302,8 +301,8 @@ describe("SettingsView - Allowed Commands", () => { expect(commands).toHaveLength(1) }) - it("saves allowed commands when clicking Done", () => { - const { onDone } = renderSettingsView() + it("saves allowed commands when clicking Save", () => { + renderSettingsView() // Enable always allow execute const executeCheckbox = screen.getByRole("checkbox", { @@ -317,9 +316,9 @@ describe("SettingsView - Allowed Commands", () => { const addButton = screen.getByText("Add") fireEvent.click(addButton) - // Click Done - const doneButton = screen.getByText("Done") - fireEvent.click(doneButton) + // Click Save + const saveButton = screen.getByText("Save") + fireEvent.click(saveButton) // Verify VSCode messages were sent expect(vscode.postMessage).toHaveBeenCalledWith( @@ -328,6 +327,5 @@ describe("SettingsView - Allowed Commands", () => { 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 54712c0e6a..a5e74f7823 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -1,16 +1,16 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" -import { useState } from "react" +import { useCallback, useState } from "react" import { useExtensionState } from "../../context/ExtensionStateContext" import { validateApiConfiguration } from "../../utils/validate" import { vscode } from "../../utils/vscode" import ApiOptions from "../settings/ApiOptions" const WelcomeView = () => { - const { apiConfiguration, currentApiConfigName } = useExtensionState() + const { apiConfiguration, currentApiConfigName, setApiConfiguration, uriScheme } = useExtensionState() const [errorMessage, setErrorMessage] = useState(undefined) - const handleSubmit = () => { + const handleSubmit = useCallback(() => { const error = validateApiConfiguration(apiConfiguration) if (error) { setErrorMessage(error) @@ -22,7 +22,7 @@ const WelcomeView = () => { text: currentApiConfigName, apiConfiguration, }) - } + }, [apiConfiguration, currentApiConfigName]) return (

@@ -37,7 +37,12 @@ const WelcomeView = () => { To get started, this extension needs an API provider.
- + setApiConfiguration({ [field]: value })} + />
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 8f4fda94c3..3dca8d5f51 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -79,7 +79,6 @@ export interface ExtensionStateContextType extends ExtensionState { setEnhancementApiConfigId: (value: string) => void setExperimentEnabled: (id: ExperimentId, enabled: boolean) => void setAutoApprovalEnabled: (value: boolean) => void - handleInputChange: (field: keyof ApiConfiguration, softUpdate?: boolean) => (event: any) => void customModes: ModeConfig[] setCustomModes: (value: ModeConfig[]) => void setMaxOpenTabsContext: (value: number) => void @@ -159,21 +158,6 @@ 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) => (event: any) => { - // Use the functional form of setState to ensure the latest state is used in the update logic. - setState((currentState) => { - return { - ...currentState, - apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value }, - } - }) - }, - [], - ) - const handleMessage = useCallback( (event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -298,7 +282,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, - apiConfiguration: value, + apiConfiguration: { + ...prevState.apiConfiguration, + ...value, + }, })), setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })), setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })), @@ -336,7 +323,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })), setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })), - handleInputChange, setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })), setMaxOpenTabsContext: (value) => setState((prevState) => ({ ...prevState, maxOpenTabsContext: value })), }