Просмотр исходного кода

Merge pull request #1051 from System233/patch-settings-panel-rename

Fix settings panel reset during state update
Matt Rubens 10 месяцев назад
Родитель
Сommit
e6f3bb7051

+ 9 - 10
src/core/webview/ClineProvider.ts

@@ -2236,18 +2236,17 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 
 			if (response.data) {
 				const rawModels: Record<string, any> = 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,
 					}
 				}
 			}

+ 2 - 1
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",

+ 6 - 11
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,10 +300,7 @@ const ApiConfigManager = ({
 					aria-labelledby="new-profile-title">
 					<DialogContent className="p-4 max-w-sm">
 						<DialogTitle>New Configuration Profile</DialogTitle>
-						<button className="absolute right-4 top-4" aria-label="Close dialog" onClick={resetCreateState}>
-							<span className="codicon codicon-close" />
-						</button>
-						<VSCodeTextField
+						<Input
 							ref={newProfileInputRef}
 							value={newProfileName}
 							onInput={(e: unknown) => {
@@ -327,15 +325,12 @@ const ApiConfigManager = ({
 							</p>
 						)}
 						<div className="flex justify-end gap-2 mt-4">
-							<VSCodeButton appearance="secondary" onClick={resetCreateState}>
+							<Button variant="secondary" onClick={resetCreateState}>
 								Cancel
-							</VSCodeButton>
-							<VSCodeButton
-								appearance="primary"
-								disabled={!newProfileName.trim()}
-								onClick={handleNewProfileSave}>
+							</Button>
+							<Button variant="default" disabled={!newProfileName.trim()} onClick={handleNewProfileSave}>
 								Create Profile
-							</VSCodeButton>
+							</Button>
 						</div>
 					</DialogContent>
 				</Dialog>

+ 198 - 353
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"
@@ -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,33 +69,40 @@ 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])
 
-	const requestLocalModelsTimeoutRef = useRef<NodeJS.Timeout | null>(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)) {
@@ -115,12 +130,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 +148,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 +186,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 +385,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 +402,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 +415,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 +469,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 +493,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 +526,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 +610,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 +624,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,15 +650,12 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 							{
 								iconName: "refresh",
 								onClick: () =>
-									handleInputChange("openAiCustomModelInfo")({
-										target: { value: openAiModelInfoSaneDefaults },
-									}),
+									setApiConfigurationField("openAiCustomModelInfo", openAiModelInfoSaneDefaults),
 							},
 						]}>
 						<div
 							style={{
-								padding: 15,
-								backgroundColor: "var(--vscode-editor-background)",
+								padding: 12,
 							}}>
 							<p
 								style={{
@@ -708,24 +669,11 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 							</p>
 
 							{/* Capabilities Section */}
-							<div
-								style={{
-									marginBottom: 20,
-									padding: 12,
-									backgroundColor: "var(--vscode-editor-inactiveSelectionBackground)",
-									borderRadius: 4,
-								}}>
-								<span
-									style={{
-										fontWeight: 500,
-										fontSize: "12px",
-										display: "block",
-										marginBottom: 12,
-										color: "var(--vscode-editor-foreground)",
-									}}>
+							<div>
+								<h3 className="font-medium text-sm text-vscode-editor-foreground">
 									Model Capabilities
-								</span>
-								<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+								</h3>
+								<div className="flex flex-col gap-2">
 									<div className="token-config-field">
 										<VSCodeTextField
 											value={
@@ -745,18 +693,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 +740,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>
@@ -832,160 +770,104 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 											</span>
 										</div>
 									</div>
+								</div>
+							</div>
 
-									<div
-										style={{
-											backgroundColor: "var(--vscode-editor-background)",
-											padding: "12px",
-											borderRadius: "4px",
-											marginTop: "8px",
-											border: "1px solid var(--vscode-input-border)",
-											transition: "background-color 0.2s ease",
-										}}>
-										<span
+							<div>
+								<h3 className="font-medium text-sm text-vscode-editor-foreground">Model Features</h3>
+								<div className="flex flex-col gap-2">
+									<div className="feature-toggle">
+										<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
+											<Checkbox
+												checked={
+													apiConfiguration?.openAiCustomModelInfo?.supportsImages ??
+													openAiModelInfoSaneDefaults.supportsImages
+												}
+												onChange={handleInputChange("openAiCustomModelInfo", (checked) => {
+													return {
+														...(apiConfiguration?.openAiCustomModelInfo ||
+															openAiModelInfoSaneDefaults),
+														supportsImages: checked,
+													}
+												})}>
+												<span style={{ fontWeight: 500 }}>Image Support</span>
+											</Checkbox>
+											<i
+												className="codicon codicon-info"
+												title="Enable if the model can process and understand images in the input. Required for image-based assistance and visual code understanding."
+												style={{
+													fontSize: "12px",
+													color: "var(--vscode-descriptionForeground)",
+													cursor: "help",
+												}}
+											/>
+										</div>
+										<p
 											style={{
 												fontSize: "11px",
-												fontWeight: 500,
-												color: "var(--vscode-editor-foreground)",
-												display: "block",
-												marginBottom: "10px",
+												color: "var(--vscode-descriptionForeground)",
+												marginLeft: "24px",
+												marginTop: "4px",
+												lineHeight: "1.4",
+												marginBottom: 0,
 											}}>
-											Model Features
-										</span>
-
-										<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
-											<div className="feature-toggle">
-												<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
-													<Checkbox
-														checked={
-															apiConfiguration?.openAiCustomModelInfo?.supportsImages ??
-															openAiModelInfoSaneDefaults.supportsImages
-														}
-														onChange={(checked: boolean) => {
-															handleInputChange("openAiCustomModelInfo")({
-																target: {
-																	value: {
-																		...(apiConfiguration?.openAiCustomModelInfo ||
-																			openAiModelInfoSaneDefaults),
-																		supportsImages: checked,
-																	},
-																},
-															})
-														}}>
-														<span style={{ fontWeight: 500 }}>Image Support</span>
-													</Checkbox>
-													<i
-														className="codicon codicon-info"
-														title="Enable if the model can process and understand images in the input. Required for image-based assistance and visual code understanding."
-														style={{
-															fontSize: "12px",
-															color: "var(--vscode-descriptionForeground)",
-															cursor: "help",
-														}}
-													/>
-												</div>
-												<p
-													style={{
-														fontSize: "11px",
-														color: "var(--vscode-descriptionForeground)",
-														marginLeft: "24px",
-														marginTop: "4px",
-														lineHeight: "1.4",
-													}}>
-													Allows the model to analyze and understand images, essential for
-													visual code assistance
-												</p>
-											</div>
+											Allows the model to analyze and understand images, essential for visual code
+											assistance
+										</p>
+									</div>
 
-											<div
-												className="feature-toggle"
+									<div
+										className="feature-toggle"
+										style={{
+											borderTop: "1px solid var(--vscode-input-border)",
+										}}>
+										<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
+											<Checkbox
+												checked={
+													apiConfiguration?.openAiCustomModelInfo?.supportsComputerUse ??
+													false
+												}
+												onChange={handleInputChange("openAiCustomModelInfo", (checked) => {
+													return {
+														...(apiConfiguration?.openAiCustomModelInfo ||
+															openAiModelInfoSaneDefaults),
+														supportsComputerUse: checked,
+													}
+												})}>
+												<span style={{ fontWeight: 500 }}>Computer Use</span>
+											</Checkbox>
+											<i
+												className="codicon codicon-info"
+												title="Enable if the model can interact with your computer through commands and file operations. Required for automated tasks and file modifications."
 												style={{
-													borderTop: "1px solid var(--vscode-input-border)",
-													paddingTop: "12px",
-												}}>
-												<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
-													<Checkbox
-														checked={
-															apiConfiguration?.openAiCustomModelInfo
-																?.supportsComputerUse ?? false
-														}
-														onChange={(checked: boolean) => {
-															handleInputChange("openAiCustomModelInfo")({
-																target: {
-																	value: {
-																		...(apiConfiguration?.openAiCustomModelInfo ||
-																			openAiModelInfoSaneDefaults),
-																		supportsComputerUse: checked,
-																	},
-																},
-															})
-														}}>
-														<span style={{ fontWeight: 500 }}>Computer Use</span>
-													</Checkbox>
-													<i
-														className="codicon codicon-info"
-														title="Enable if the model can interact with your computer through commands and file operations. Required for automated tasks and file modifications."
-														style={{
-															fontSize: "12px",
-															color: "var(--vscode-descriptionForeground)",
-															cursor: "help",
-														}}
-													/>
-												</div>
-												<p
-													style={{
-														fontSize: "11px",
-														color: "var(--vscode-descriptionForeground)",
-														marginLeft: "24px",
-														marginTop: "4px",
-														lineHeight: "1.4",
-													}}>
-													This model feature is for computer use like sonnet 3.5 support
-												</p>
-											</div>
+													fontSize: "12px",
+													color: "var(--vscode-descriptionForeground)",
+													cursor: "help",
+												}}
+											/>
 										</div>
+										<p
+											style={{
+												fontSize: "11px",
+												color: "var(--vscode-descriptionForeground)",
+												marginLeft: "24px",
+												marginTop: "4px",
+												lineHeight: "1.4",
+												marginBottom: 0,
+											}}>
+											This model feature is for computer use like sonnet 3.5 support
+										</p>
 									</div>
 								</div>
 							</div>
 
 							{/* Pricing Section */}
-							<div
-								style={{
-									backgroundColor: "var(--vscode-editor-inactiveSelectionBackground)",
-									padding: "12px",
-									borderRadius: "4px",
-									marginTop: "15px",
-								}}>
-								<div style={{ marginBottom: "12px" }}>
-									<span
-										style={{
-											fontWeight: 500,
-											fontSize: "12px",
-											color: "var(--vscode-editor-foreground)",
-											display: "block",
-											marginBottom: "4px",
-										}}>
-										Model Pricing
-									</span>
-									<span
-										style={{
-											fontSize: "11px",
-											color: "var(--vscode-descriptionForeground)",
-											display: "block",
-										}}>
-										Configure token-based pricing in USD per million tokens
-									</span>
-								</div>
-
-								<div
-									style={{
-										display: "grid",
-										gridTemplateColumns: "1fr 1fr",
-										gap: "12px",
-										backgroundColor: "var(--vscode-editor-background)",
-										padding: "12px",
-										borderRadius: "4px",
-									}}>
+							<div>
+								<h3 className="font-medium text-sm text-vscode-editor-foreground mb-0">
+									Model Pricing
+								</h3>
+								<div className="text-xs">Configure token-based pricing in USD per million tokens</div>
+								<div className="flex flex-row gap-2 mt-1.5">
 									<div className="price-input">
 										<VSCodeTextField
 											value={
@@ -1004,23 +886,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 +931,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 +1007,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 +1086,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 +1151,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}
@@ -1373,6 +1220,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						fontSize: 12,
 						color: "var(--vscode-errorForeground)",
 					}}>
+					<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
 					{apiErrorMessage}
 				</p>
 			)}
@@ -1416,11 +1264,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>
@@ -1433,6 +1277,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
 						fontSize: 12,
 						color: "var(--vscode-errorForeground)",
 					}}>
+					<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
 					{modelIdErrorMessage}
 				</p>
 			)}

+ 229 - 144
webview-ui/src/components/settings/SettingsView.tsx

@@ -1,78 +1,137 @@
 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"
 import ExperimentalFeature from "./ExperimentalFeature"
-import { EXPERIMENT_IDS, experimentConfigsMap } from "../../../../src/shared/experiments"
+import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments"
 import ApiConfigManager from "./ApiConfigManager"
 import { Dropdown } from "vscrui"
 import type { DropdownOption } from "vscrui"
+import { ApiConfiguration } from "../../../../src/shared/api"
+import {
+	AlertDialog,
+	AlertDialogContent,
+	AlertDialogTitle,
+	AlertDialogDescription,
+	AlertDialogCancel,
+	AlertDialogAction,
+	AlertDialogHeader,
+	AlertDialogFooter,
+} from "../ui/alert-dialog"
 
 type SettingsViewProps = {
 	onDone: () => void
 }
 
 const SettingsView = ({ onDone }: SettingsViewProps) => {
+	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)
+	const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
+
+	// 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<string | undefined>(undefined)
-	const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(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(
+		<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: {
+						...prevState.apiConfiguration,
+						[field]: value,
+					},
+				}
+			})
+		},
+		[],
+	)
+
+	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)
-		const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
+		const modelIdValidationResult = validateModelId(
+			apiConfiguration,
+			extensionState.glamaModels,
+			extensionState.openRouterModels,
+		)
 
 		setApiErrorMessage(apiValidationResult)
 		setModelIdErrorMessage(modelIdValidationResult)
@@ -98,19 +157,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 +181,32 @@ 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 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" })
@@ -135,7 +216,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",
@@ -151,13 +232,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 		paddingBottom: "2px",
 	}
 
-	const sliderStyle = {
-		flexGrow: 1,
-		maxWidth: "80%",
-		accentColor: "var(--vscode-button-background)",
-		height: "2px",
-	}
-
 	return (
 		<div
 			style={{
@@ -171,6 +245,21 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 				flexDirection: "column",
 				overflow: "hidden",
 			}}>
+			<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
+				<AlertDialogContent>
+					<AlertDialogHeader>
+						<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
+						<AlertDialogDescription>
+							<span style={{ fontSize: "2em" }} className={`codicon codicon-warning align-middle mr-1`} />
+							Do you want to discard changes and continue?
+						</AlertDialogDescription>
+					</AlertDialogHeader>
+					<AlertDialogFooter>
+						<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>Yes</AlertDialogAction>
+						<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>No</AlertDialogCancel>
+					</AlertDialogFooter>
+				</AlertDialogContent>
+			</AlertDialog>
 			<div
 				style={{
 					display: "flex",
@@ -180,7 +269,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={() => checkUnsaveChanges(onDone)}>
+						Done
+					</VSCodeButton>
+				</div>
 			</div>
 			<div
 				style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
@@ -189,16 +297,13 @@ 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,
+								checkUnsaveChanges(() => {
+									vscode.postMessage({
+										type: "loadApiConfiguration",
+										text: configName,
+									})
 								})
 							}}
 							onDeleteConfig={(configName: string) => {
@@ -213,6 +318,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									values: { oldName, newName },
 									apiConfiguration,
 								})
+								prevApiConfigName.current = newName
 							}}
 							onUpsertConfig={(configName: string) => {
 								vscode.postMessage({
@@ -222,7 +328,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 +349,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 +366,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,12 +386,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 										max="5000"
 										step="100"
 										value={writeDelayMs}
-										onChange={(e) => setWriteDelayMs(parseInt(e.target.value))}
-										style={{
-											flex: 1,
-											accentColor: "var(--vscode-button-background)",
-											height: "2px",
-										}}
+										onChange={(e) => setCachedStateField("writeDelayMs", parseInt(e.target.value))}
+										className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
 									/>
 									<span style={{ minWidth: "45px", textAlign: "left" }}>{writeDelayMs}ms</span>
 								</div>
@@ -298,7 +406,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 +419,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,12 +439,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 										max="100"
 										step="1"
 										value={requestDelaySeconds}
-										onChange={(e) => setRequestDelaySeconds(parseInt(e.target.value))}
-										style={{
-											flex: 1,
-											accentColor: "var(--vscode-button-background)",
-											height: "2px",
-										}}
+										onChange={(e) =>
+											setCachedStateField("requestDelaySeconds", parseInt(e.target.value))
+										}
+										className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
 									/>
 									<span style={{ minWidth: "45px", textAlign: "left" }}>{requestDelaySeconds}s</span>
 								</div>
@@ -355,7 +461,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 +473,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 +485,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)" }}>
@@ -430,35 +536,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									{(allowedCommands ?? []).map((cmd, index) => (
 										<div
 											key={index}
-											style={{
-												display: "flex",
-												alignItems: "center",
-												gap: "5px",
-												backgroundColor: "var(--vscode-button-secondaryBackground)",
-												padding: "2px 6px",
-												borderRadius: "4px",
-												border: "1px solid var(--vscode-button-secondaryBorder)",
-												height: "24px",
-											}}>
+											className="border border-vscode-input-border bg-primary text-primary-foreground flex items-center gap-1 rounded-xs px-1.5 p-0.5">
 											<span>{cmd}</span>
 											<VSCodeButton
 												appearance="icon"
-												style={{
-													padding: 0,
-													margin: 0,
-													height: "20px",
-													width: "20px",
-													minWidth: "20px",
-													display: "flex",
-													alignItems: "center",
-													justifyContent: "center",
-													color: "var(--vscode-button-foreground)",
-												}}
+												className="text-primary-foreground"
 												onClick={() => {
 													const newCommands = (allowedCommands ?? []).filter(
 														(_, i) => i !== index,
 													)
-													setAllowedCommands(newCommands)
+													setCachedStateField("allowedCommands", newCommands)
 													vscode.postMessage({
 														type: "allowedCommands",
 														commands: newCommands,
@@ -482,7 +569,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,10 +601,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									max="100"
 									step="1"
 									value={screenshotQuality ?? 75}
-									onChange={(e) => setScreenshotQuality(parseInt(e.target.value))}
-									style={{
-										...sliderStyle,
-									}}
+									className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
+									onChange={(e) => setCachedStateField("screenshotQuality", parseInt(e.target.value))}
 								/>
 								<span style={{ ...sliderLabelStyle }}>{screenshotQuality ?? 75}%</span>
 							</div>
@@ -537,7 +622,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,12 +651,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									max="1"
 									step="0.01"
 									value={soundVolume ?? 0.5}
-									onChange={(e) => setSoundVolume(parseFloat(e.target.value))}
-									style={{
-										flexGrow: 1,
-										accentColor: "var(--vscode-button-background)",
-										height: "2px",
-									}}
+									onChange={(e) => setCachedStateField("soundVolume", parseFloat(e.target.value))}
+									className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
 									aria-label="Volume"
 								/>
 								<span style={{ minWidth: "35px", textAlign: "left" }}>
@@ -592,8 +675,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									max="60"
 									step="1"
 									value={rateLimitSeconds}
-									onChange={(e) => setRateLimitSeconds(parseInt(e.target.value))}
-									style={{ ...sliderStyle }}
+									onChange={(e) => setCachedStateField("rateLimitSeconds", parseInt(e.target.value))}
+									className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
 								/>
 								<span style={{ ...sliderLabelStyle }}>{rateLimitSeconds}s</span>
 							</div>
@@ -612,8 +695,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									max="5000"
 									step="100"
 									value={terminalOutputLineLimit ?? 500}
-									onChange={(e) => setTerminalOutputLineLimit(parseInt(e.target.value))}
-									style={{ ...sliderStyle }}
+									onChange={(e) =>
+										setCachedStateField("terminalOutputLineLimit", parseInt(e.target.value))
+									}
+									className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
 								/>
 								<span style={{ ...sliderLabelStyle }}>{terminalOutputLineLimit ?? 500}</span>
 							</div>
@@ -634,8 +719,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									max="500"
 									step="1"
 									value={maxOpenTabsContext ?? 20}
-									onChange={(e) => setMaxOpenTabsContext(parseInt(e.target.value))}
-									style={{ ...sliderStyle }}
+									onChange={(e) =>
+										setCachedStateField("maxOpenTabsContext", parseInt(e.target.value))
+									}
+									className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
 								/>
 								<span style={{ ...sliderLabelStyle }}>{maxOpenTabsContext ?? 20}</span>
 							</div>
@@ -650,7 +737,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,11 +776,9 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 											step="0.005"
 											value={fuzzyMatchThreshold ?? 1.0}
 											onChange={(e) => {
-												setFuzzyMatchThreshold(parseFloat(e.target.value))
-											}}
-											style={{
-												...sliderStyle,
+												setCachedStateField("fuzzyMatchThreshold", parseFloat(e.target.value))
 											}}
+											className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
 										/>
 										<span style={{ ...sliderLabelStyle }}>
 											{Math.round((fuzzyMatchThreshold || 1) * 100)}%
@@ -727,7 +812,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 +868,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

+ 14 - 25
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
 					}
 				}}>
 				<span style={{ fontWeight: "500" }}>Use custom temperature</span>
@@ -48,27 +49,15 @@ export const TemperatureControl = ({ value, onChange, maxValue = 1 }: Temperatur
 					}}>
 					<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
 						<input
-							aria-label="Temperature control text input"
-							type="text"
+							type="range"
+							min="0"
+							max={maxValue}
+							step="0.01"
 							value={inputValue}
-							onChange={(e) => 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))}
 						/>
+						<span>{inputValue}</span>
 					</div>
 					<p style={{ fontSize: "12px", marginTop: "8px", color: "var(--vscode-descriptionForeground)" }}>
 						Higher values make output more random, lower values make it more deterministic.

+ 6 - 1
webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx

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

+ 11 - 13
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()
 	})
 })

+ 18 - 8
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(<TemperatureControl value={0.7} onChange={onChange} />)
 
@@ -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(<TemperatureControl value={0.7} onChange={onChange} />)
 
-		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(<TemperatureControl value={1.5} onChange={onChange} maxValue={2} />)
 
-		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", () => {

+ 108 - 0
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<typeof AlertDialogPrimitive.Overlay>,
+	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+	<AlertDialogPrimitive.Overlay
+		className={cn(
+			"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+			className,
+		)}
+		{...props}
+		ref={ref}
+	/>
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+	React.ElementRef<typeof AlertDialogPrimitive.Content>,
+	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
+>(({ className, ...props }, ref) => (
+	<AlertDialogPortal>
+		<AlertDialogOverlay />
+		<AlertDialogPrimitive.Content
+			ref={ref}
+			className={cn(
+				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
+				className,
+			)}
+			{...props}
+		/>
+	</AlertDialogPortal>
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+	<div className={cn("flex flex-col space-y-2 text-left", className)} {...props} />
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+	<div className={cn("flex flex-row justify-end space-x-2", className)} {...props} />
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+	React.ElementRef<typeof AlertDialogPrimitive.Title>,
+	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+	<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+	React.ElementRef<typeof AlertDialogPrimitive.Description>,
+	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+	<AlertDialogPrimitive.Description
+		ref={ref}
+		className={cn("text-base text-muted-foreground", className)}
+		{...props}
+	/>
+))
+AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+	React.ElementRef<typeof AlertDialogPrimitive.Action>,
+	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
+>(({ className, ...props }, ref) => (
+	<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+	React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
+	React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
+>(({ className, ...props }, ref) => (
+	<AlertDialogPrimitive.Cancel
+		ref={ref}
+		className={cn(buttonVariants({ variant: "secondary" }), "mt-0", className)}
+		{...props}
+	/>
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+	AlertDialog,
+	AlertDialogPortal,
+	AlertDialogOverlay,
+	AlertDialogTrigger,
+	AlertDialogContent,
+	AlertDialogHeader,
+	AlertDialogFooter,
+	AlertDialogTitle,
+	AlertDialogDescription,
+	AlertDialogAction,
+	AlertDialogCancel,
+}

+ 11 - 8
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",

+ 2 - 2
webview-ui/src/components/ui/command.tsx

@@ -43,7 +43,7 @@ const CommandInput = React.forwardRef<
 		<CommandPrimitive.Input
 			ref={ref}
 			className={cn(
-				"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
+				"flex h-10 w-full rounded-md bg-transparent py-3 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
 				className,
 			)}
 			{...props}
@@ -108,7 +108,7 @@ const CommandItem = React.forwardRef<
 	<CommandPrimitive.Item
 		ref={ref}
 		className={cn(
-			"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm text-vscode-dropdown-foreground outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+			"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-base text-vscode-dropdown-foreground outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
 			className,
 		)}
 		{...props}

+ 3 - 3
webview-ui/src/components/ui/dialog.tsx

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
 	<DialogPrimitive.Overlay
 		ref={ref}
 		className={cn(
-			"fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+			"fixed inset-0 z-50 bg-black/50  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
 			className,
 		)}
 		{...props}
@@ -38,12 +38,12 @@ const DialogContent = React.forwardRef<
 		<DialogPrimitive.Content
 			ref={ref}
 			className={cn(
-				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+				"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
 				className,
 			)}
 			{...props}>
 			{children}
-			<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+			<DialogPrimitive.Close className="cursor-pointer absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
 				<Cross2Icon className="h-4 w-4" />
 				<span className="sr-only">Close</span>
 			</DialogPrimitive.Close>

+ 1 - 1
webview-ui/src/components/ui/input.tsx

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
 			<input
 				type={type}
 				className={cn(
-					"flex w-full text-vscode-input-foreground border border-vscode-input-border bg-vscode-input-background rounded-xs px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50",
+					"flex w-full text-vscode-input-foreground border border-vscode-dropdown-border  bg-vscode-input-background rounded-xs px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50",
 					className,
 				)}
 				ref={ref}

+ 11 - 6
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<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,10 +37,15 @@ 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">
+			<div className="sticky bottom-0 bg-[var(--vscode-sideBar-background)] py-3">
 				<div className="flex flex-col gap-1.5">
 					<VSCodeButton onClick={handleSubmit}>Let's go!</VSCodeButton>
 					{errorMessage && <span className="text-destructive">{errorMessage}</span>}

+ 4 - 18
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 })),
 	}