From eeb1e45907986c1f4869b8738b3b5aedd951804a Mon Sep 17 00:00:00 2001 From: System233 Date: Thu, 20 Feb 2025 06:09:39 +0800 Subject: [PATCH] Improve UI experience --- webview-ui/package.json | 3 +- .../components/settings/ApiConfigManager.tsx | 14 +- .../src/components/settings/ApiOptions.tsx | 286 +++++++----------- .../src/components/settings/SettingsView.tsx | 96 +++--- .../settings/TemperatureControl.tsx | 39 +-- .../__tests__/TemperatureControl.test.tsx | 26 +- webview-ui/src/components/ui/alert-dialog.tsx | 108 +++++++ webview-ui/src/components/ui/button.tsx | 19 +- .../src/components/ui/comfirm-dialog.tsx | 58 ---- webview-ui/src/components/ui/command.tsx | 4 +- webview-ui/src/components/ui/dialog.tsx | 2 +- webview-ui/src/components/ui/input.tsx | 2 +- 12 files changed, 303 insertions(+), 354 deletions(-) create mode 100644 webview-ui/src/components/ui/alert-dialog.tsx delete mode 100644 webview-ui/src/components/ui/comfirm-dialog.tsx diff --git a/webview-ui/package.json b/webview-ui/package.json index 44647355ce..49bd0decbc 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -15,6 +15,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.5", @@ -23,7 +24,7 @@ "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.3", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.0", "@vscode/webview-ui-toolkit": "^1.4.0", diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 7d969eace8..7e2c63c530 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -4,6 +4,7 @@ import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage" import { Dropdown } from "vscrui" import type { DropdownOption } from "vscrui" import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" +import { Button, Input } from "../ui" interface ApiConfigManagerProps { currentApiConfigName?: string @@ -299,7 +300,7 @@ const ApiConfigManager = ({ aria-labelledby="new-profile-title"> New Configuration Profile - { @@ -324,15 +325,12 @@ const ApiConfigManager = ({

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

{/* Capabilities Section */} -

- +
+

Model Capabilities - -
+

+
+
+
-
- +

Model Features

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

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

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

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

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

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

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

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

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

+ Model Pricing +

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

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

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

- - {message} -

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