From 2036e84d9d6acdde8c9aabe741dd48c245501f18 Mon Sep 17 00:00:00 2001 From: System233 Date: Tue, 18 Feb 2025 23:47:57 +0800 Subject: [PATCH 1/6] Fix settings panel reset during state update --- .../src/components/settings/ApiOptions.tsx | 303 +++++++----------- .../src/components/settings/SettingsView.tsx | 249 +++++++++----- .../settings/__tests__/ApiOptions.test.tsx | 7 +- .../settings/__tests__/SettingsView.test.tsx | 24 +- .../src/components/welcome/WelcomeView.tsx | 15 +- .../src/context/ExtensionStateContext.tsx | 22 +- 6 files changed, 303 insertions(+), 317 deletions(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index e75113f9236..3888efaa8a6 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 bef57d1f5ae..fc5818abe92 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 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, - version, alwaysAllowReadOnly, - setAlwaysAllowReadOnly, - alwaysAllowWrite, - setAlwaysAllowWrite, - alwaysAllowExecute, - setAlwaysAllowExecute, + allowedCommands, alwaysAllowBrowser, - setAlwaysAllowBrowser, + alwaysAllowExecute, alwaysAllowMcp, - setAlwaysAllowMcp, - soundEnabled, - setSoundEnabled, - soundVolume, - setSoundVolume, - diffEnabled, - setDiffEnabled, - checkpointsEnabled, - setCheckpointsEnabled, + alwaysAllowModeSwitch, + alwaysAllowWrite, + alwaysApproveResubmit, browserViewportSize, - setBrowserViewportSize, - openRouterModels, - glamaModels, - setAllowedCommands, - allowedCommands, + checkpointsEnabled, + diffEnabled, + experiments, fuzzyMatchThreshold, - setFuzzyMatchThreshold, - writeDelayMs, - setWriteDelayMs, - screenshotQuality, - setScreenshotQuality, - terminalOutputLineLimit, - setTerminalOutputLineLimit, + maxOpenTabsContext, mcpEnabled, - alwaysApproveResubmit, - setAlwaysApproveResubmit, - requestDelaySeconds, - setRequestDelaySeconds, rateLimitSeconds, - setRateLimitSeconds, - currentApiConfigName, - listApiConfigMeta, - experiments, - setExperimentEnabled, - alwaysAllowModeSwitch, - setAlwaysAllowModeSwitch, - maxOpenTabsContext, - setMaxOpenTabsContext, - } = useExtensionState() - const [apiErrorMessage, setApiErrorMessage] = useState(undefined) - const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) - const [commandInput, setCommandInput] = useState("") + 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: "upsertApiConfiguration", - text: currentApiConfigName, - apiConfiguration, - }) - vscode.postMessage({ type: "updateExperimental", values: experiments, }) - vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) - onDone() + + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration, + }) + // 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 37654326107..99bc0a87c23 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 54712c0e6a5..a5e74f78230 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 8f4fda94c3d..3dca8d5f51c 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 })), } From 1fcdd3333924432a95d66d1757fe0793e288f19c Mon Sep 17 00:00:00 2001 From: System233 Date: Wed, 19 Feb 2025 02:15:14 +0800 Subject: [PATCH 2/6] Add confirmation dialog for discarding changes. --- .../components/settings/ApiConfigManager.tsx | 3 - .../src/components/settings/SettingsView.tsx | 66 +++++++++++++++---- .../src/components/ui/comfirm-dialog.tsx | 58 ++++++++++++++++ webview-ui/src/components/ui/dialog.tsx | 4 +- 4 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 webview-ui/src/components/ui/comfirm-dialog.tsx diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 15c9a2b0c0c..7d969eace8c 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -299,9 +299,6 @@ const ApiConfigManager = ({ aria-labelledby="new-profile-title"> New Configuration Profile - void @@ -21,6 +22,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) const [commandInput, setCommandInput] = useState("") const prevApiConfigName = useRef(extensionState.currentApiConfigName) + const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) // TODO: Reduce WebviewMessage/ExtensionState complexity const [cachedState, setCachedState] = useState(extensionState) @@ -67,7 +69,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { }, [currentApiConfigName, extensionState, isChangeDetected]) const setCachedStateField = useCallback( - (field: K, value: ExtensionStateContextType[K]) => + (field: K, value: ExtensionStateContextType[K]) => { setCachedState((prevState) => { if (prevState[field] === value) { return prevState @@ -77,7 +79,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { ...prevState, [field]: value, } - }), + }) + }, [], ) @@ -91,20 +94,27 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { return { ...prevState, apiConfiguration: { - ...apiConfiguration, + ...prevState.apiConfiguration, [field]: value, }, } }) }, - [apiConfiguration], + [], ) - const setExperimentEnabled = useCallback( - (id: string, enabled: boolean) => - setCachedStateField("experiments", { ...cachedState.experiments, [id]: enabled }), - [cachedState.experiments, setCachedStateField], - ) + const setExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => { + setCachedState((prevState) => { + if (prevState.experiments?.[id] === enabled) { + return prevState + } + setChangeDetected(true) + return { + ...prevState, + experiments: { ...prevState.experiments, [id]: enabled }, + } + }) + }, []) const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) @@ -171,6 +181,24 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setModelIdErrorMessage(modelIdValidationResult) }, [apiConfiguration, extensionState.glamaModels, extensionState.openRouterModels]) + const confirmDialogHandler = useRef<() => void>() + const onConfirmDialogResult = useCallback((confirm: boolean) => { + if (confirm) { + confirmDialogHandler.current?.() + } + }, []) + const checkUnsaveChanges = useCallback( + (then: () => void) => { + if (isChangeDetected) { + confirmDialogHandler.current = then + setDiscardDialogShow(true) + } else { + then() + } + }, + [isChangeDetected], + ) + const handleResetState = () => { vscode.postMessage({ type: "resetState" }) } @@ -215,6 +243,14 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { flexDirection: "column", overflow: "hidden", }}> + setDiscardDialogShow(false)} + aria-labelledby="unsave-warning-dialog">
{ + onClick={() => checkUnsaveChanges(onDone)}> Done
@@ -254,9 +290,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { currentApiConfigName={currentApiConfigName} listApiConfigMeta={extensionState.listApiConfigMeta} onSelectConfig={(configName: string) => { - vscode.postMessage({ - type: "loadApiConfiguration", - text: configName, + checkUnsaveChanges(() => { + vscode.postMessage({ + type: "loadApiConfiguration", + text: configName, + }) }) }} onDeleteConfig={(configName: string) => { diff --git a/webview-ui/src/components/ui/comfirm-dialog.tsx b/webview-ui/src/components/ui/comfirm-dialog.tsx new file mode 100644 index 00000000000..35d8f999cc0 --- /dev/null +++ b/webview-ui/src/components/ui/comfirm-dialog.tsx @@ -0,0 +1,58 @@ +import { Dialog, DialogContent, DialogTitle } from "./dialog" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { useCallback } from "react" + +export interface ConfirmDialogProps { + show: boolean + icon: string + title?: string + message: string + onResult: (confirm: boolean) => void + onClose: () => void +} +export const ConfirmDialog = ({ onResult, onClose, icon, show, title, message }: ConfirmDialogProps) => { + const onCloseConfirmDialog = useCallback( + (confirm: boolean) => { + onResult(confirm) + onClose() + }, + [onClose, onResult], + ) + return ( + { + !open && onCloseConfirmDialog(false) + }} + aria-labelledby="unsave-warning-dialog"> + + {title} +

+ + {message} +

+
+ { + onCloseConfirmDialog(true) + }}> + Yes + + { + onCloseConfirmDialog(false) + }}> + No + +
+
+
+ ) +} + +export default ConfirmDialog diff --git a/webview-ui/src/components/ui/dialog.tsx b/webview-ui/src/components/ui/dialog.tsx index 9025b03de18..a8c3542a540 100644 --- a/webview-ui/src/components/ui/dialog.tsx +++ b/webview-ui/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< {children} - + Close From e5f5cbc79ceb9d12ecda4281238baaa938d71e34 Mon Sep 17 00:00:00 2001 From: System233 Date: Wed, 19 Feb 2025 05:31:41 +0800 Subject: [PATCH 3/6] Fix the sticky background color of the welcome view --- webview-ui/src/components/welcome/WelcomeView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index a5e74f78230..858d2622f39 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -45,7 +45,7 @@ const WelcomeView = () => { />
-
+
Let's go! {errorMessage && {errorMessage}} From 2d34cda0ea1e76e36fc4fc072059273e9696f4d5 Mon Sep 17 00:00:00 2001 From: System233 Date: Wed, 19 Feb 2025 05:46:54 +0800 Subject: [PATCH 4/6] Add icon feedback for apiErrorMessage/modelIdErrorMessage --- webview-ui/src/components/settings/ApiOptions.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 3888efaa8a6..9dd30d4e9ee 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1296,6 +1296,7 @@ const ApiOptions = ({ fontSize: 12, color: "var(--vscode-errorForeground)", }}> + {apiErrorMessage}

)} @@ -1352,6 +1353,7 @@ const ApiOptions = ({ fontSize: 12, color: "var(--vscode-errorForeground)", }}> + {modelIdErrorMessage}

)} From b732005d23277a5b2a79e07e4eb37ab439f49f30 Mon Sep 17 00:00:00 2001 From: System233 Date: Wed, 19 Feb 2025 19:58:13 +0800 Subject: [PATCH 5/6] fix: getunbound.ai may return null --- src/core/webview/ClineProvider.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2f3cf4ed540..448b1a748a6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2220,18 +2220,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (response.data) { const rawModels: Record = response.data - for (const [modelId, model] of Object.entries(rawModels)) { models[modelId] = { - maxTokens: model.maxTokens ? parseInt(model.maxTokens) : undefined, - contextWindow: model.contextWindow ? parseInt(model.contextWindow) : 0, - supportsImages: model.supportsImages ?? false, - supportsPromptCache: model.supportsPromptCaching ?? false, - supportsComputerUse: model.supportsComputerUse ?? false, - inputPrice: model.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined, - outputPrice: model.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined, - cacheWritesPrice: model.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined, - cacheReadsPrice: model.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined, + maxTokens: model?.maxTokens ? parseInt(model.maxTokens) : undefined, + contextWindow: model?.contextWindow ? parseInt(model.contextWindow) : 0, + supportsImages: model?.supportsImages ?? false, + supportsPromptCache: model?.supportsPromptCaching ?? false, + supportsComputerUse: model?.supportsComputerUse ?? false, + inputPrice: model?.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined, + outputPrice: model?.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined, + cacheWritesPrice: model?.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined, + cacheReadsPrice: model?.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined, } } } From eeb1e45907986c1f4869b8738b3b5aedd951804a Mon Sep 17 00:00:00 2001 From: System233 Date: Thu, 20 Feb 2025 06:09:39 +0800 Subject: [PATCH 6/6] Improve UI experience --- webview-ui/package.json | 3 +- .../components/settings/ApiConfigManager.tsx | 14 +- .../src/components/settings/ApiOptions.tsx | 286 +++++++----------- .../src/components/settings/SettingsView.tsx | 96 +++--- .../settings/TemperatureControl.tsx | 39 +-- .../__tests__/TemperatureControl.test.tsx | 26 +- webview-ui/src/components/ui/alert-dialog.tsx | 108 +++++++ webview-ui/src/components/ui/button.tsx | 19 +- .../src/components/ui/comfirm-dialog.tsx | 58 ---- webview-ui/src/components/ui/command.tsx | 4 +- webview-ui/src/components/ui/dialog.tsx | 2 +- webview-ui/src/components/ui/input.tsx | 2 +- 12 files changed, 303 insertions(+), 354 deletions(-) create mode 100644 webview-ui/src/components/ui/alert-dialog.tsx delete mode 100644 webview-ui/src/components/ui/comfirm-dialog.tsx diff --git a/webview-ui/package.json b/webview-ui/package.json index 44647355ce5..49bd0decbc8 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -15,6 +15,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.5", @@ -23,7 +24,7 @@ "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.3", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.0", "@vscode/webview-ui-toolkit": "^1.4.0", diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 7d969eace8c..7e2c63c5308 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -4,6 +4,7 @@ import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage" import { Dropdown } from "vscrui" import type { DropdownOption } from "vscrui" import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" +import { Button, Input } from "../ui" interface ApiConfigManagerProps { currentApiConfigName?: string @@ -299,7 +300,7 @@ const ApiConfigManager = ({ aria-labelledby="new-profile-title"> New Configuration Profile - { @@ -324,15 +325,12 @@ const ApiConfigManager = ({

)}
- + +
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 9dd30d4e9ee..19813a1f6d5 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, useRef, useState } from "react" -import { useEvent } from "react-use" +import { memo, useCallback, useMemo, useState } from "react" +import { useDebounce, 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" @@ -88,29 +88,21 @@ const ApiOptions = ({ return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) - const requestLocalModelsTimeoutRef = useRef(null) // Pull ollama/lmstudio models - const requestLocalModels = useCallback(() => { - if (selectedProvider === "ollama") { - vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl }) - } else if (selectedProvider === "lmstudio") { - vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl }) - } else if (selectedProvider === "vscode-lm") { - vscode.postMessage({ type: "requestVsCodeLmModels" }) - } - }, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl]) // Debounced model updates, only executed 250ms after the user stops typing - useEffect(() => { - if (requestLocalModelsTimeoutRef.current) { - clearTimeout(requestLocalModelsTimeoutRef.current) - } - requestLocalModelsTimeoutRef.current = setTimeout(requestLocalModels, 250) - return () => { - if (requestLocalModelsTimeoutRef.current) { - clearTimeout(requestLocalModelsTimeoutRef.current) + useDebounce( + () => { + if (selectedProvider === "ollama") { + vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl }) + } else if (selectedProvider === "lmstudio") { + vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl }) + } else if (selectedProvider === "vscode-lm") { + vscode.postMessage({ type: "requestVsCodeLmModels" }) } - } - }, [requestLocalModels]) + }, + 250, + [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl], + ) const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data if (message.type === "ollamaModels" && Array.isArray(message.ollamaModels)) { @@ -663,8 +655,7 @@ const ApiOptions = ({ ]}>

{/* Capabilities Section */} -

- +
+

Model Capabilities - -
+

+
+
+
-
- +

Model Features

+
+
+
+ { + return { + ...(apiConfiguration?.openAiCustomModelInfo || + openAiModelInfoSaneDefaults), + supportsImages: checked, + } + })}> + Image Support + + +
+

- Model Features - - -

-
-
- { - return { - ...(apiConfiguration?.openAiCustomModelInfo || - openAiModelInfoSaneDefaults), - supportsImages: checked, - } - }, - )}> - Image Support - - -
-

- Allows the model to analyze and understand images, essential for - visual code assistance -

-
+ Allows the model to analyze and understand images, essential for visual code + assistance +

+
-
+
+ { + return { + ...(apiConfiguration?.openAiCustomModelInfo || + openAiModelInfoSaneDefaults), + supportsComputerUse: checked, + } + })}> + Computer Use + + -
- { - return { - ...(apiConfiguration?.openAiCustomModelInfo || - openAiModelInfoSaneDefaults), - supportsComputerUse: checked, - } - }, - )}> - Computer Use - - -
-

- This model feature is for computer use like sonnet 3.5 support -

-
+ fontSize: "12px", + color: "var(--vscode-descriptionForeground)", + cursor: "help", + }} + />
+

+ This model feature is for computer use like sonnet 3.5 support +

{/* Pricing Section */} -
-
- - Model Pricing - - - Configure token-based pricing in USD per million tokens - -
- -
+
+

+ Model Pricing +

+
Configure token-based pricing in USD per million tokens
+
void @@ -223,13 +232,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { paddingBottom: "2px", } - const sliderStyle = { - flexGrow: 1, - maxWidth: "80%", - accentColor: "var(--vscode-button-background)", - height: "2px", - } - return (
{ flexDirection: "column", overflow: "hidden", }}> - setDiscardDialogShow(false)} - aria-labelledby="unsave-warning-dialog"> + + + + Unsaved changes + + + Do you want to discard changes and continue? + + + + onConfirmDialogResult(true)}>Yes + onConfirmDialogResult(false)}>No + + +
{ step="100" value={writeDelayMs} onChange={(e) => setCachedStateField("writeDelayMs", parseInt(e.target.value))} - style={{ - flex: 1, - accentColor: "var(--vscode-button-background)", - height: "2px", - }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" /> {writeDelayMs}ms
@@ -437,11 +442,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { onChange={(e) => setCachedStateField("requestDelaySeconds", parseInt(e.target.value)) } - style={{ - flex: 1, - accentColor: "var(--vscode-button-background)", - height: "2px", - }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" /> {requestDelaySeconds}s
@@ -535,30 +536,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { {(allowedCommands ?? []).map((cmd, index) => (
+ className="border border-vscode-input-border bg-primary text-primary-foreground flex items-center gap-1 rounded-xs px-1.5 p-0.5"> {cmd} { const newCommands = (allowedCommands ?? []).filter( (_, i) => i !== index, @@ -619,10 +601,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { max="100" step="1" value={screenshotQuality ?? 75} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" onChange={(e) => setCachedStateField("screenshotQuality", parseInt(e.target.value))} - style={{ - ...sliderStyle, - }} /> {screenshotQuality ?? 75}%
@@ -672,11 +652,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { step="0.01" value={soundVolume ?? 0.5} onChange={(e) => setCachedStateField("soundVolume", parseFloat(e.target.value))} - style={{ - flexGrow: 1, - accentColor: "var(--vscode-button-background)", - height: "2px", - }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" aria-label="Volume" /> @@ -700,7 +676,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { step="1" value={rateLimitSeconds} onChange={(e) => setCachedStateField("rateLimitSeconds", parseInt(e.target.value))} - style={{ ...sliderStyle }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" /> {rateLimitSeconds}s
@@ -722,7 +698,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { onChange={(e) => setCachedStateField("terminalOutputLineLimit", parseInt(e.target.value)) } - style={{ ...sliderStyle }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" /> {terminalOutputLineLimit ?? 500}
@@ -746,7 +722,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { onChange={(e) => setCachedStateField("maxOpenTabsContext", parseInt(e.target.value)) } - style={{ ...sliderStyle }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" /> {maxOpenTabsContext ?? 20}
@@ -802,9 +778,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { onChange={(e) => { setCachedStateField("fuzzyMatchThreshold", parseFloat(e.target.value)) }} - style={{ - ...sliderStyle, - }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" /> {Math.round((fuzzyMatchThreshold || 1) * 100)}% diff --git a/webview-ui/src/components/settings/TemperatureControl.tsx b/webview-ui/src/components/settings/TemperatureControl.tsx index 422356bf69f..cbafcc55203 100644 --- a/webview-ui/src/components/settings/TemperatureControl.tsx +++ b/webview-ui/src/components/settings/TemperatureControl.tsx @@ -1,5 +1,6 @@ import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" import { useEffect, useState } from "react" +import { useDebounce } from "react-use" interface TemperatureControlProps { value: number | undefined @@ -9,13 +10,13 @@ interface TemperatureControlProps { export const TemperatureControl = ({ value, onChange, maxValue = 1 }: TemperatureControlProps) => { const [isCustomTemperature, setIsCustomTemperature] = useState(value !== undefined) - const [inputValue, setInputValue] = useState(value?.toString() ?? "0") - + const [inputValue, setInputValue] = useState(value) + useDebounce(() => onChange(inputValue), 50, [onChange, inputValue]) // Sync internal state with prop changes when switching profiles useEffect(() => { const hasCustomTemperature = value !== undefined setIsCustomTemperature(hasCustomTemperature) - setInputValue(value?.toString() ?? "0") + setInputValue(value) }, [value]) return ( @@ -26,9 +27,9 @@ export const TemperatureControl = ({ value, onChange, maxValue = 1 }: Temperatur const isChecked = e.target.checked setIsCustomTemperature(isChecked) if (!isChecked) { - onChange(undefined) // Unset the temperature - } else if (value !== undefined) { - onChange(value) // Use the value from apiConfiguration, if set + setInputValue(undefined) // Unset the temperature + } else { + setInputValue(value ?? 0) // Use the value from apiConfiguration, if set } }}> Use custom temperature @@ -48,27 +49,15 @@ export const TemperatureControl = ({ value, onChange, maxValue = 1 }: Temperatur }}>
setInputValue(e.target.value)} - onBlur={(e) => { - const newValue = parseFloat(e.target.value) - if (!isNaN(newValue) && newValue >= 0 && newValue <= maxValue) { - onChange(newValue) - setInputValue(newValue.toString()) - } else { - setInputValue(value?.toString() ?? "0") // Reset to last valid value - } - }} - style={{ - width: "60px", - padding: "4px 8px", - border: "1px solid var(--vscode-input-border)", - background: "var(--vscode-input-background)", - color: "var(--vscode-input-foreground)", - }} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + onChange={(e) => setInputValue(parseFloat(e.target.value))} /> + {inputValue}

Higher values make output more random, lower values make it more deterministic. diff --git a/webview-ui/src/components/settings/__tests__/TemperatureControl.test.tsx b/webview-ui/src/components/settings/__tests__/TemperatureControl.test.tsx index d178cfafbc9..95d0babfdbf 100644 --- a/webview-ui/src/components/settings/__tests__/TemperatureControl.test.tsx +++ b/webview-ui/src/components/settings/__tests__/TemperatureControl.test.tsx @@ -18,12 +18,12 @@ describe("TemperatureControl", () => { const checkbox = screen.getByRole("checkbox") expect(checkbox).toBeChecked() - const input = screen.getByRole("textbox") + const input = screen.getByRole("slider") expect(input).toBeInTheDocument() expect(input).toHaveValue("0.7") }) - it("updates when checkbox is toggled", () => { + it("updates when checkbox is toggled", async () => { const onChange = jest.fn() render() @@ -31,40 +31,50 @@ describe("TemperatureControl", () => { // Uncheck - should clear temperature fireEvent.click(checkbox) + // Waiting for debounce + await new Promise((x) => setTimeout(x, 100)) expect(onChange).toHaveBeenCalledWith(undefined) // Check - should restore previous temperature fireEvent.click(checkbox) + // Waiting for debounce + await new Promise((x) => setTimeout(x, 100)) expect(onChange).toHaveBeenCalledWith(0.7) }) - it("updates temperature when input loses focus", () => { + it("updates temperature when input loses focus", async () => { const onChange = jest.fn() render() - const input = screen.getByRole("textbox") + const input = screen.getByRole("slider") fireEvent.change(input, { target: { value: "0.8" } }) fireEvent.blur(input) + // Waiting for debounce + await new Promise((x) => setTimeout(x, 100)) expect(onChange).toHaveBeenCalledWith(0.8) }) - it("respects maxValue prop", () => { + it("respects maxValue prop", async () => { const onChange = jest.fn() render() - const input = screen.getByRole("textbox") + const input = screen.getByRole("slider") // Valid value within max fireEvent.change(input, { target: { value: "1.8" } }) fireEvent.blur(input) + // Waiting for debounce + await new Promise((x) => setTimeout(x, 100)) expect(onChange).toHaveBeenCalledWith(1.8) // Invalid value above max fireEvent.change(input, { target: { value: "2.5" } }) fireEvent.blur(input) - expect(input).toHaveValue("1.5") // Should revert to original value - expect(onChange).toHaveBeenCalledTimes(1) // Should not call onChange for invalid value + expect(input).toHaveValue("2") // Clamped between 0 and 2 + // Waiting for debounce + await new Promise((x) => setTimeout(x, 100)) + expect(onChange).toHaveBeenCalledWith(2) }) it("syncs checkbox state when value prop changes", () => { diff --git a/webview-ui/src/components/ui/alert-dialog.tsx b/webview-ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000000..7530cae54d6 --- /dev/null +++ b/webview-ui/src/components/ui/alert-dialog.tsx @@ -0,0 +1,108 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +

+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/webview-ui/src/components/ui/button.tsx b/webview-ui/src/components/ui/button.tsx index a9664ce06f2..9f60dfedeec 100644 --- a/webview-ui/src/components/ui/button.tsx +++ b/webview-ui/src/components/ui/button.tsx @@ -5,19 +5,22 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer active:opacity-90", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs text-base font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "text-primary-foreground bg-primary shadow hover:bg-primary/90", - secondary: "text-secondary-foreground bg-secondary shadow-sm hover:bg-secondary/80", + default: + "border border-vscode-input-border bg-primary text-primary-foreground shadow hover:bg-primary/90 cursor-pointer", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 cursor-pointer", outline: - "text-secondary-foreground bg-vscode-editor-background border border-vscode-dropdown-border shadow-sm hover:bg-vscode-editor-background/50", - ghost: "text-secondary-foreground hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - destructive: "text-destructive-foreground bg-destructive shadow-sm hover:bg-destructive/90", + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground cursor-pointer", + secondary: + "border border-vscode-input-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 cursor-pointer", + ghost: "hover:bg-accent hover:text-accent-foreground cursor-pointer", + link: "text-primary underline-offset-4 hover:underline cursor-pointer", combobox: - "text-secondary-foreground bg-vscode-input-background border border-vscode-input-border hover:bg-vscode-input-background/80", + "text-vscode-font-size font-normal text-popover-foreground bg-vscode-input-background border border-vscode-dropdown-border hover:bg-vscode-input-background/80 cursor-pointer", }, size: { default: "h-7 px-3", diff --git a/webview-ui/src/components/ui/comfirm-dialog.tsx b/webview-ui/src/components/ui/comfirm-dialog.tsx deleted file mode 100644 index 35d8f999cc0..00000000000 --- a/webview-ui/src/components/ui/comfirm-dialog.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Dialog, DialogContent, DialogTitle } from "./dialog" -import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" -import { useCallback } from "react" - -export interface ConfirmDialogProps { - show: boolean - icon: string - title?: string - message: string - onResult: (confirm: boolean) => void - onClose: () => void -} -export const ConfirmDialog = ({ onResult, onClose, icon, show, title, message }: ConfirmDialogProps) => { - const onCloseConfirmDialog = useCallback( - (confirm: boolean) => { - onResult(confirm) - onClose() - }, - [onClose, onResult], - ) - return ( - { - !open && onCloseConfirmDialog(false) - }} - aria-labelledby="unsave-warning-dialog"> - - {title} -

- - {message} -

-
- { - onCloseConfirmDialog(true) - }}> - Yes - - { - onCloseConfirmDialog(false) - }}> - No - -
-
-
- ) -} - -export default ConfirmDialog diff --git a/webview-ui/src/components/ui/command.tsx b/webview-ui/src/components/ui/command.tsx index 95803511396..99e987599c4 100644 --- a/webview-ui/src/components/ui/command.tsx +++ b/webview-ui/src/components/ui/command.tsx @@ -43,7 +43,7 @@ const CommandInput = React.forwardRef< diff --git a/webview-ui/src/components/ui/input.tsx b/webview-ui/src/components/ui/input.tsx index 511cefee893..77bea85dad6 100644 --- a/webview-ui/src/components/ui/input.tsx +++ b/webview-ui/src/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef>(