Fix settings panel reset during state update

This commit is contained in:
System233 2025-02-18 23:47:57 +08:00
parent 2e52b1c368
commit 2036e84d9d
6 changed files with 309 additions and 323 deletions

View file

@ -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: <K extends keyof ApiConfiguration>(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<string[]>([])
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
@ -61,6 +69,21 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const inputEventTransform = <E,>(event: E) => (event as { target: HTMLInputElement })?.target?.value as any
const noTransform = <T,>(value: T) => value
const dropdownEventTransform = <T,>(event: DropdownOption | string | undefined) =>
(typeof event == "string" ? event : event?.value) as T
const handleInputChange = useCallback(
<K extends keyof ApiConfiguration, E>(
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
<Dropdown
id="model-id"
value={selectedModelId}
onChange={(value: unknown) => {
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
<Dropdown
id="api-provider"
value={selectedProvider}
onChange={(value: unknown) => {
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
)}
<Checkbox
checked={apiConfiguration?.openRouterUseMiddleOutTransform || false}
onChange={(checked: boolean) => {
handleInputChange("openRouterUseMiddleOutTransform")({
target: { value: checked },
})
}}>
onChange={handleInputChange("openRouterUseMiddleOutTransform", noTransform)}>
Compress prompts and message chains to the context size (
<a href="https://openrouter.ai/docs/transforms">OpenRouter Transforms</a>)
</Checkbox>
@ -422,13 +423,10 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<VSCodeRadioGroup
value={apiConfiguration?.awsUseProfile ? "profile" : "credentials"}
onChange={(e) => {
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",
)}>
<VSCodeRadio value="credentials">AWS Credentials</VSCodeRadio>
<VSCodeRadio value="profile">AWS Profile</VSCodeRadio>
</VSCodeRadioGroup>
@ -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
</div>
<Checkbox
checked={apiConfiguration?.awsUseCrossRegionInference || false}
onChange={(checked: boolean) => {
handleInputChange("awsUseCrossRegionInference")({
target: { value: checked },
})
}}>
onChange={handleInputChange("awsUseCrossRegionInference", noTransform)}>
Use cross-region inference
</Checkbox>
<p
@ -544,13 +534,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
id="vertex-region-dropdown"
value={apiConfiguration?.vertexRegion || ""}
style={{ width: "100%" }}
onChange={(value: unknown) => {
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
<div style={{ display: "flex", alignItems: "center" }}>
<Checkbox
checked={apiConfiguration?.openAiStreamingEnabled ?? true}
onChange={(checked: boolean) => {
handleInputChange("openAiStreamingEnabled")({
target: { value: checked },
})
}}>
onChange={handleInputChange("openAiStreamingEnabled", noTransform)}>
Enable streaming
</Checkbox>
</div>
<Checkbox
checked={apiConfiguration?.openAiUseAzure ?? false}
onChange={(checked: boolean) => {
handleInputChange("openAiUseAzure")({
target: { value: checked },
})
}}>
onChange={handleInputChange("openAiUseAzure", noTransform)}>
Use Azure
</Checkbox>
<Checkbox
@ -656,11 +632,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
onChange={(checked: boolean) => {
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),
},
]}>
<div
@ -745,18 +715,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
})(),
}}
title="Maximum number of tokens the model can generate in a single response"
onChange={(e: any) => {
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">
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
</VSCodeTextField>
@ -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">
<span style={{ fontWeight: 500 }}>Context Window Size</span>
</VSCodeTextField>
@ -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,
}
},
)}>
<span style={{ fontWeight: 500 }}>Image Support</span>
</Checkbox>
<i
@ -909,17 +868,16 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
apiConfiguration?.openAiCustomModelInfo
?.supportsComputerUse ?? false
}
onChange={(checked: boolean) => {
handleInputChange("openAiCustomModelInfo")({
target: {
value: {
...(apiConfiguration?.openAiCustomModelInfo ||
openAiModelInfoSaneDefaults),
supportsComputerUse: checked,
},
},
})
}}>
onChange={handleInputChange(
"openAiCustomModelInfo",
(checked) => {
return {
...(apiConfiguration?.openAiCustomModelInfo ||
openAiModelInfoSaneDefaults),
supportsComputerUse: checked,
}
},
)}>
<span style={{ fontWeight: 500 }}>Computer Use</span>
</Checkbox>
<i
@ -1004,23 +962,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),
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">
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ fontWeight: 500 }}>Input Price</span>
@ -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">
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ fontWeight: 500 }}>Output Price</span>
@ -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) => (
<VSCodeRadio
key={model}
@ -1224,18 +1162,11 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}`
: ""
}
onChange={(value: unknown) => {
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) => (
<VSCodeRadio
key={model}
@ -1416,11 +1339,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
<div style={{ marginTop: "10px" }}>
<TemperatureControl
value={apiConfiguration?.modelTemperature}
onChange={(value) => {
handleInputChange("modelTemperature")({
target: { value },
})
}}
onChange={handleInputChange("modelTemperature", noTransform)}
maxValue={2}
/>
</div>

View file

@ -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<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(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(
<K extends keyof ExtensionStateContextType>(field: K, value: ExtensionStateContextType[K]) =>
setCachedState((prevState) => {
if (prevState[field] === value) {
return prevState
}
setChangeDetected(true)
return {
...prevState,
[field]: value,
}
}),
[],
)
const setApiConfigurationField = useCallback(
<K extends keyof ApiConfiguration>(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,
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: "6px",
}}>
<VSCodeButton
appearance="primary"
title={isChangeDetected ? "Save changes" : "Nothing changed"}
onClick={handleSubmit}
disabled={!isChangeDetected}>
Save
</VSCodeButton>
<VSCodeButton
appearance="secondary"
title="Discard unsaved changes and close settings panel"
onClick={onDone}>
Done
</VSCodeButton>
</div>
</div>
<div
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
@ -189,13 +252,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 15 }}>
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
listApiConfigMeta={extensionState.listApiConfigMeta}
onSelectConfig={(configName: string) => {
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) => {
})
}}
/>
<ApiOptions apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} />
<ApiOptions
uriScheme={extensionState.uriScheme}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
apiErrorMessage={apiErrorMessage}
modelIdErrorMessage={modelIdErrorMessage}
/>
</div>
</div>
@ -237,7 +302,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={alwaysAllowReadOnly}
onChange={(e: any) => setAlwaysAllowReadOnly(e.target.checked)}>
onChange={(e: any) => setCachedStateField("alwaysAllowReadOnly", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve read-only operations</span>
</VSCodeCheckbox>
<p
@ -254,7 +319,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={alwaysAllowWrite}
onChange={(e: any) => setAlwaysAllowWrite(e.target.checked)}>
onChange={(e: any) => setCachedStateField("alwaysAllowWrite", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve write operations</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
@ -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) => {
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={alwaysAllowBrowser}
onChange={(e: any) => setAlwaysAllowBrowser(e.target.checked)}>
onChange={(e: any) => setCachedStateField("alwaysAllowBrowser", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
@ -311,7 +376,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={alwaysApproveResubmit}
onChange={(e: any) => setAlwaysApproveResubmit(e.target.checked)}>
onChange={(e: any) => setCachedStateField("alwaysApproveResubmit", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always retry failed API requests</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
@ -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) => {
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowMcp}
onChange={(e: any) => setAlwaysAllowMcp(e.target.checked)}>
onChange={(e: any) => setCachedStateField("alwaysAllowMcp", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
@ -367,7 +434,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={alwaysAllowModeSwitch}
onChange={(e: any) => setAlwaysAllowModeSwitch(e.target.checked)}>
onChange={(e: any) => setCachedStateField("alwaysAllowModeSwitch", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve mode switching & task creation</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
@ -379,7 +446,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={alwaysAllowExecute}
onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
onChange={(e: any) => setCachedStateField("alwaysAllowExecute", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve allowed execute operations</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
@ -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) => {
<Dropdown
value={browserViewportSize}
onChange={(value: unknown) => {
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) => {
<div style={{ marginBottom: 40 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 15px 0" }}>Notification Settings</h3>
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<VSCodeCheckbox
checked={soundEnabled}
onChange={(e: any) => setCachedStateField("soundEnabled", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
</VSCodeCheckbox>
<p
@ -564,7 +633,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
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 }}
/>
<span style={{ ...sliderLabelStyle }}>{rateLimitSeconds}s</span>
@ -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 }}
/>
<span style={{ ...sliderLabelStyle }}>{terminalOutputLineLimit ?? 500}</span>
@ -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 }}
/>
<span style={{ ...sliderLabelStyle }}>{maxOpenTabsContext ?? 20}</span>
@ -650,7 +723,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<VSCodeCheckbox
checked={diffEnabled}
onChange={(e: any) => {
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) => {
<VSCodeCheckbox
checked={checkpointsEnabled}
onChange={(e: any) => {
setCheckpointsEnabled(e.target.checked)
setCachedStateField("checkpointsEnabled", e.target.checked)
}}>
<span style={{ fontWeight: "500" }}>Enable experimental checkpoints</span>
</VSCodeCheckbox>
@ -783,7 +856,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</VSCodeLink>
</p>
<p style={{ fontStyle: "italic", margin: "10px 0 0 0", padding: 0, marginBottom: 100 }}>
v{version}
v{extensionState.version}
</p>
<p

View file

@ -50,7 +50,12 @@ describe("ApiOptions", () => {
const renderApiOptions = (props = {}) => {
render(
<ExtensionStateContextProvider>
<ApiOptions {...props} />
<ApiOptions
uriScheme={undefined}
apiConfiguration={{}}
setApiConfigurationField={() => {}}
{...props}
/>
</ExtensionStateContextProvider>,
)
}

View file

@ -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()
})
})

View file

@ -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<string | undefined>(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 (
<div className="flex flex-col min-h-screen px-0 pb-5">
@ -37,7 +37,12 @@ const WelcomeView = () => {
<b>To get started, this extension needs an API provider.</b>
<div className="mt-3">
<ApiOptions fromWelcomeView />
<ApiOptions
fromWelcomeView
apiConfiguration={apiConfiguration || {}}
uriScheme={uriScheme}
setApiConfigurationField={(field, value) => setApiConfiguration({ [field]: value })}
/>
</div>
<div className="sticky bottom-0 bg-[var(--vscode-editor-background)] py-3">

View file

@ -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 })),
}