diff --git a/webview-ui/jest.config.cjs b/webview-ui/jest.config.cjs index 69ed93166e..6ee94dda39 100644 --- a/webview-ui/jest.config.cjs +++ b/webview-ui/jest.config.cjs @@ -6,7 +6,7 @@ module.exports = { moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], transform: { "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }] }, testMatch: ["/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "/src/**/*.{spec,test}.{js,jsx,ts,tsx}"], - setupFilesAfterEnv: ["/src/setupTests.ts", "@testing-library/jest-dom/extend-expect"], + setupFilesAfterEnv: ["/src/setupTests.ts"], moduleNameMapper: { "\\.(css|less|scss|sass)$": "identity-obj-proxy", "^vscrui$": "/src/__mocks__/vscrui.ts", diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 6590035968..b44ade2851 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -42,9 +42,9 @@ "@storybook/react": "^8.5.2", "@storybook/react-vite": "^8.5.2", "@storybook/test": "^8.5.2", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^27.5.2", "@types/node": "^18.0.0", "@types/react": "^18.3.18", @@ -5498,24 +5498,22 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", - "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", "dev": true, "license": "MIT", "dependencies": { - "@adobe/css-tools": "^4.0.1", - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", + "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", "redent": "^3.0.0" }, "engines": { - "node": ">=8", + "node": ">=14", "npm": ">=6", "yarn": ">=1" } @@ -5534,66 +5532,49 @@ "node": ">=8" } }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", - "integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz", + "integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.5.0", - "@types/react-dom": "^18.0.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=12" + "node": ">=18" }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@testing-library/react/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "deep-equal": "^2.0.5" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@testing-library/user-event": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, "engines": { - "node": ">=10", + "node": ">=12", "npm": ">=6" }, "peerDependencies": { @@ -8255,39 +8236,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8638,27 +8586,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-iterator-helpers": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", @@ -13201,23 +13128,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -14923,20 +14833,6 @@ "stacktrace-gps": "^3.0.4" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/storybook": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.5.2.tgz", diff --git a/webview-ui/package.json b/webview-ui/package.json index 077bff5609..d7a5765690 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -48,9 +48,9 @@ "@storybook/react": "^8.5.2", "@storybook/react-vite": "^8.5.2", "@storybook/test": "^8.5.2", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^27.5.2", "@types/node": "^18.0.0", "@types/react": "^18.3.18", diff --git a/webview-ui/src/components/history/__tests__/HistoryView.test.tsx b/webview-ui/src/components/history/__tests__/HistoryView.test.tsx index 1f1e4c7daf..d47c5e460b 100644 --- a/webview-ui/src/components/history/__tests__/HistoryView.test.tsx +++ b/webview-ui/src/components/history/__tests__/HistoryView.test.tsx @@ -1,13 +1,13 @@ -import React from "react" -import { render, screen, fireEvent, within, waitFor } from "@testing-library/react" -import userEvent from "@testing-library/user-event" +// cd webview-ui && npx jest src/components/history/__tests__/HistoryView.test.ts + +import { render, screen, fireEvent, within, act } from "@testing-library/react" import HistoryView from "../HistoryView" import { useExtensionState } from "../../../context/ExtensionStateContext" import { vscode } from "../../../utils/vscode" -// Mock dependencies jest.mock("../../../context/ExtensionStateContext") jest.mock("../../../utils/vscode") + jest.mock("react-virtuoso", () => ({ Virtuoso: ({ data, itemContent }: any) => (
@@ -41,21 +41,21 @@ const mockTaskHistory = [ ] describe("HistoryView", () => { - beforeEach(() => { - // Reset all mocks before each test - jest.clearAllMocks() + beforeAll(() => { jest.useFakeTimers() + }) - // Mock useExtensionState implementation + afterAll(() => { + jest.useRealTimers() + }) + + beforeEach(() => { + jest.clearAllMocks() ;(useExtensionState as jest.Mock).mockReturnValue({ taskHistory: mockTaskHistory, }) }) - afterEach(() => { - jest.useRealTimers() - }) - it("renders history items correctly", () => { const onDone = jest.fn() render() @@ -67,7 +67,7 @@ describe("HistoryView", () => { expect(screen.getByText("Test task 2")).toBeInTheDocument() }) - it("handles search functionality", async () => { + it("handles search functionality", () => { const onDone = jest.fn() render() @@ -76,17 +76,23 @@ describe("HistoryView", () => { const radioGroup = screen.getByRole("radiogroup") // Type in search - await userEvent.type(searchInput, "task 1") + fireEvent.input(searchInput, { target: { value: "task 1" } }) + + // Advance timers to process search state update + jest.advanceTimersByTime(100) // Check if sort option automatically changes to "Most Relevant" const mostRelevantRadio = within(radioGroup).getByLabelText("Most Relevant") expect(mostRelevantRadio).not.toBeDisabled() - // Click and wait for radio update + // Click the radio button fireEvent.click(mostRelevantRadio) - // Wait for radio button to be checked - const updatedRadio = await within(radioGroup).findByRole("radio", { name: "Most Relevant", checked: true }) + // Advance timers to process radio button state update + jest.advanceTimersByTime(100) + + // Verify radio button is checked + const updatedRadio = within(radioGroup).getByRole("radio", { name: "Most Relevant", checked: true }) expect(updatedRadio).toBeInTheDocument() }) @@ -148,6 +154,7 @@ describe("HistoryView", () => { }) it("handles task copying", async () => { + // Setup clipboard mock that resolves immediately const mockClipboard = { writeText: jest.fn().mockResolvedValue(undefined), } @@ -161,20 +168,29 @@ describe("HistoryView", () => { fireEvent.mouseEnter(taskContainer) const copyButton = within(taskContainer).getByTitle("Copy Prompt") - await userEvent.click(copyButton) - // Verify clipboard API was called + // Click the copy button and wait for clipboard operation + await act(async () => { + fireEvent.click(copyButton) + // Let the clipboard Promise resolve + await Promise.resolve() + // Let React process the first state update + await Promise.resolve() + }) + + // Verify clipboard was called expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Test task 1") - // Wait for copy modal to appear - const copyModal = await screen.findByText("Prompt Copied to Clipboard") - expect(copyModal).toBeInTheDocument() + // Verify modal appears immediately after clipboard operation + expect(screen.getByText("Prompt Copied to Clipboard")).toBeInTheDocument() - // Fast-forward timers and wait for modal to disappear - jest.advanceTimersByTime(2000) - await waitFor(() => { - expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument() + // Advance timer to trigger the setTimeout for modal disappearance + act(() => { + jest.advanceTimersByTime(2000) }) + + // Verify modal is gone + expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument() }) it("formats dates correctly", () => { diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 277a532c48..4f9c8e1b78 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1,8 +1,9 @@ -import { Checkbox, Dropdown, Pane } from "vscrui" -import type { DropdownOption } from "vscrui" -import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react" +import { memo, useCallback, useEffect, useMemo, useState } from "react" import { useEvent, useInterval } from "react-use" +import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui" +import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import * as vscodemodels from "vscode" + import { ApiConfiguration, ModelInfo, @@ -32,14 +33,12 @@ import { import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" -import * as vscodemodels from "vscode" import VSCodeButtonLink from "../common/VSCodeButtonLink" -import OpenRouterModelPicker, { - ModelDescriptionMarkdown, - OPENROUTER_MODEL_PICKER_Z_INDEX, -} from "./OpenRouterModelPicker" +import { OpenRouterModelPicker } from "./OpenRouterModelPicker" import OpenAiModelPicker from "./OpenAiModelPicker" -import GlamaModelPicker from "./GlamaModelPicker" +import { GlamaModelPicker } from "./GlamaModelPicker" +import { ModelInfoView } from "./ModelInfoView" +import { DROPDOWN_Z_INDEX } from "./styles" interface ApiOptionsProps { apiErrorMessage?: string @@ -137,7 +136,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = }, }) }} - style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }} + style={{ minWidth: 130, position: "relative", zIndex: DROPDOWN_Z_INDEX + 1 }} options={[ { value: "openrouter", label: "OpenRouter" }, { value: "anthropic", label: "Anthropic" }, @@ -1386,136 +1385,6 @@ export function getOpenRouterAuthUrl(uriScheme?: string) { return `https://openrouter.ai/auth?callback_url=${uriScheme || "vscode"}://rooveterinaryinc.roo-cline/openrouter` } -export const formatPrice = (price: number) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(price) -} - -export const ModelInfoView = ({ - selectedModelId, - modelInfo, - isDescriptionExpanded, - setIsDescriptionExpanded, -}: { - selectedModelId: string - modelInfo: ModelInfo - isDescriptionExpanded: boolean - setIsDescriptionExpanded: (isExpanded: boolean) => void -}) => { - const isGemini = Object.keys(geminiModels).includes(selectedModelId) - - const infoItems = [ - modelInfo.description && ( - - ), - , - , - !isGemini && ( - - ), - modelInfo.maxTokens !== undefined && modelInfo.maxTokens > 0 && ( - - Max output: {modelInfo.maxTokens?.toLocaleString()} tokens - - ), - modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 && ( - - Input price: {formatPrice(modelInfo.inputPrice)}/million tokens - - ), - modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && ( - - Cache writes price:{" "} - {formatPrice(modelInfo.cacheWritesPrice || 0)}/million tokens - - ), - modelInfo.supportsPromptCache && modelInfo.cacheReadsPrice && ( - - Cache reads price:{" "} - {formatPrice(modelInfo.cacheReadsPrice || 0)}/million tokens - - ), - modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 && ( - - Output price: {formatPrice(modelInfo.outputPrice)}/million - tokens - - ), - isGemini && ( - - * Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute. - After that, billing depends on prompt size.{" "} - - For more info, see pricing details. - - - ), - ].filter(Boolean) - - return ( -

- {infoItems.map((item, index) => ( - - {item} - {index < infoItems.length - 1 &&
} -
- ))} -

- ) -} - -const ModelInfoSupportsItem = ({ - isSupported, - supportsLabel, - doesNotSupportLabel, -}: { - isSupported: boolean - supportsLabel: string - doesNotSupportLabel: string -}) => ( - - - {isSupported ? supportsLabel : doesNotSupportLabel} - -) - export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { const provider = apiConfiguration?.apiProvider || "anthropic" const modelId = apiConfiguration?.apiModelId diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 07d75bec79..cb813a0d05 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -1,415 +1,15 @@ -import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import debounce from "debounce" -import { Fzf } from "fzf" -import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" -import { useRemark } from "react-remark" -import { useMount } from "react-use" -import styled from "styled-components" +import { ModelPicker } from "./ModelPicker" import { glamaDefaultModelId } from "../../../../src/shared/api" -import { useExtensionState } from "../../context/ExtensionStateContext" -import { vscode } from "../../utils/vscode" -import { highlightFzfMatch } from "../../utils/highlight" -import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" -const GlamaModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState() - const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId) - const [isDropdownVisible, setIsDropdownVisible] = useState(false) - const [selectedIndex, setSelectedIndex] = useState(-1) - const dropdownRef = useRef(null) - const itemRefs = useRef<(HTMLDivElement | null)[]>([]) - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) - const dropdownListRef = useRef(null) - - const handleModelChange = (newModelId: string) => { - // could be setting invalid model id/undefined info but validation will catch it - const apiConfig = { - ...apiConfiguration, - glamaModelId: newModelId, - glamaModelInfo: glamaModels[newModelId], - } - setApiConfiguration(apiConfig) - onUpdateApiConfig(apiConfig) - - setSearchTerm(newModelId) - } - - const { selectedModelId, selectedModelInfo } = useMemo(() => { - return normalizeApiConfiguration(apiConfiguration) - }, [apiConfiguration]) - - useEffect(() => { - if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) { - setSearchTerm(apiConfiguration?.glamaModelId) - } - }, [apiConfiguration, searchTerm]) - - const debouncedRefreshModels = useMemo( - () => - debounce(() => { - vscode.postMessage({ type: "refreshGlamaModels" }) - }, 50), - [], - ) - - useMount(() => { - debouncedRefreshModels() - - // Cleanup debounced function - return () => { - debouncedRefreshModels.clear() - } - }) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownVisible(false) - } - } - - document.addEventListener("mousedown", handleClickOutside) - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, []) - - const modelIds = useMemo(() => { - return Object.keys(glamaModels).sort((a, b) => a.localeCompare(b)) - }, [glamaModels]) - - const searchableItems = useMemo(() => { - return modelIds.map((id) => ({ - id, - html: id, - })) - }, [modelIds]) - - const fzf = useMemo(() => { - return new Fzf(searchableItems, { - selector: (item) => item.html, - }) - }, [searchableItems]) - - const modelSearchResults = useMemo(() => { - if (!searchTerm) return searchableItems - - const searchResults = fzf.find(searchTerm) - return searchResults.map((result) => ({ - ...result.item, - html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"), - })) - }, [searchableItems, searchTerm, fzf]) - - const handleKeyDown = (event: KeyboardEvent) => { - if (!isDropdownVisible) return - - switch (event.key) { - case "ArrowDown": - event.preventDefault() - setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev)) - break - case "ArrowUp": - event.preventDefault() - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)) - break - case "Enter": - event.preventDefault() - if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) { - handleModelChange(modelSearchResults[selectedIndex].id) - setIsDropdownVisible(false) - } - break - case "Escape": - setIsDropdownVisible(false) - setSelectedIndex(-1) - break - } - } - - const hasInfo = useMemo(() => { - return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase()) - }, [modelIds, searchTerm]) - - useEffect(() => { - setSelectedIndex(-1) - if (dropdownListRef.current) { - dropdownListRef.current.scrollTop = 0 - } - }, [searchTerm]) - - useEffect(() => { - if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) { - itemRefs.current[selectedIndex]?.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }) - } - }, [selectedIndex]) - - return ( - <> - -
- - - { - handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase()) - setIsDropdownVisible(true) - }} - onFocus={() => setIsDropdownVisible(true)} - onKeyDown={handleKeyDown} - style={{ width: "100%", zIndex: GLAMA_MODEL_PICKER_Z_INDEX, position: "relative" }}> - {searchTerm && ( -
{ - handleModelChange("") - setIsDropdownVisible(true) - }} - slot="end" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - height: "100%", - }} - /> - )} - - {isDropdownVisible && ( - - {modelSearchResults.map((item, index) => ( - (itemRefs.current[index] = el)} - isSelected={index === selectedIndex} - onMouseEnter={() => setSelectedIndex(index)} - onClick={() => { - handleModelChange(item.id) - setIsDropdownVisible(false) - }} - dangerouslySetInnerHTML={{ - __html: item.html, - }} - /> - ))} - - )} - -
- - {hasInfo ? ( - - ) : ( -

- The extension automatically fetches the latest list of models available on{" "} - - Glama. - - If you're unsure which model to choose, Roo Code works best with{" "} - handleModelChange("anthropic/claude-3.5-sonnet")}> - anthropic/claude-3.5-sonnet. - - You can also try searching "free" for no-cost options currently available. -

- )} - - ) -} - -export default GlamaModelPicker - -// Dropdown - -const DropdownWrapper = styled.div` - position: relative; - width: 100%; -` - -export const GLAMA_MODEL_PICKER_Z_INDEX = 1_000 - -const DropdownList = styled.div` - position: absolute; - top: calc(100% - 3px); - left: 0; - width: calc(100% - 2px); - max-height: 200px; - overflow-y: auto; - background-color: var(--vscode-dropdown-background); - border: 1px solid var(--vscode-list-activeSelectionBackground); - z-index: ${GLAMA_MODEL_PICKER_Z_INDEX - 1}; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -` - -const DropdownItem = styled.div<{ isSelected: boolean }>` - padding: 5px 10px; - cursor: pointer; - word-break: break-all; - white-space: normal; - - background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; - - &:hover { - background-color: var(--vscode-list-activeSelectionBackground); - } -` - -// Markdown - -const StyledMarkdown = styled.div` - font-family: - var(--vscode-font-family), - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - Oxygen, - Ubuntu, - Cantarell, - "Open Sans", - "Helvetica Neue", - sans-serif; - font-size: 12px; - color: var(--vscode-descriptionForeground); - - p, - li, - ol, - ul { - line-height: 1.25; - margin: 0; - } - - ol, - ul { - padding-left: 1.5em; - margin-left: 0; - } - - p { - white-space: pre-wrap; - } - - a { - text-decoration: none; - } - a { - &:hover { - text-decoration: underline; - } - } -` - -export const ModelDescriptionMarkdown = memo( - ({ - markdown, - key, - isExpanded, - setIsExpanded, - }: { - markdown?: string - key: string - isExpanded: boolean - setIsExpanded: (isExpanded: boolean) => void - }) => { - const [reactContent, setMarkdown] = useRemark() - const [showSeeMore, setShowSeeMore] = useState(false) - const textContainerRef = useRef(null) - const textRef = useRef(null) - - useEffect(() => { - setMarkdown(markdown || "") - }, [markdown, setMarkdown]) - - useEffect(() => { - if (textRef.current && textContainerRef.current) { - const { scrollHeight } = textRef.current - const { clientHeight } = textContainerRef.current - const isOverflowing = scrollHeight > clientHeight - setShowSeeMore(isOverflowing) - } - }, [reactContent, setIsExpanded]) - - return ( - -
-
- {reactContent} -
- {!isExpanded && showSeeMore && ( -
-
- setIsExpanded(true)}> - See more - -
- )} -
- - ) - }, +export const GlamaModelPicker = () => ( + ) diff --git a/webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx b/webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx new file mode 100644 index 0000000000..351464f706 --- /dev/null +++ b/webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx @@ -0,0 +1,90 @@ +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { memo, useEffect, useRef, useState } from "react" +import { useRemark } from "react-remark" + +import { StyledMarkdown } from "./styles" + +export const ModelDescriptionMarkdown = memo( + ({ + markdown, + key, + isExpanded, + setIsExpanded, + }: { + markdown?: string + key: string + isExpanded: boolean + setIsExpanded: (isExpanded: boolean) => void + }) => { + const [reactContent, setMarkdown] = useRemark() + const [showSeeMore, setShowSeeMore] = useState(false) + const textContainerRef = useRef(null) + const textRef = useRef(null) + + useEffect(() => { + setMarkdown(markdown || "") + }, [markdown, setMarkdown]) + + useEffect(() => { + if (textRef.current && textContainerRef.current) { + const { scrollHeight } = textRef.current + const { clientHeight } = textContainerRef.current + const isOverflowing = scrollHeight > clientHeight + setShowSeeMore(isOverflowing) + } + }, [reactContent, setIsExpanded]) + + return ( + +
+
+ {reactContent} +
+ {!isExpanded && showSeeMore && ( +
+
+ setIsExpanded(true)}> + See more + +
+ )} +
+ + ) + }, +) diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx new file mode 100644 index 0000000000..397d04e02f --- /dev/null +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -0,0 +1,124 @@ +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { Fragment } from "react" + +import { ModelInfo, geminiModels } from "../../../../src/shared/api" +import { ModelDescriptionMarkdown } from "./ModelDescriptionMarkdown" +import { formatPrice } from "../../utils/formatPrice" + +export const ModelInfoView = ({ + selectedModelId, + modelInfo, + isDescriptionExpanded, + setIsDescriptionExpanded, +}: { + selectedModelId: string + modelInfo: ModelInfo + isDescriptionExpanded: boolean + setIsDescriptionExpanded: (isExpanded: boolean) => void +}) => { + const isGemini = Object.keys(geminiModels).includes(selectedModelId) + + const infoItems = [ + modelInfo.description && ( + + ), + , + , + !isGemini && ( + + ), + modelInfo.maxTokens !== undefined && modelInfo.maxTokens > 0 && ( + + Max output: {modelInfo.maxTokens?.toLocaleString()} tokens + + ), + modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 && ( + + Input price: {formatPrice(modelInfo.inputPrice)}/million tokens + + ), + modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && ( + + Cache writes price:{" "} + {formatPrice(modelInfo.cacheWritesPrice || 0)}/million tokens + + ), + modelInfo.supportsPromptCache && modelInfo.cacheReadsPrice && ( + + Cache reads price:{" "} + {formatPrice(modelInfo.cacheReadsPrice || 0)}/million tokens + + ), + modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 && ( + + Output price: {formatPrice(modelInfo.outputPrice)}/million + tokens + + ), + isGemini && ( + + * Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute. + After that, billing depends on prompt size.{" "} + + For more info, see pricing details. + + + ), + ].filter(Boolean) + + return ( +
+ {infoItems.map((item, index) => ( + + {item} + {index < infoItems.length - 1 &&
} +
+ ))} +
+ ) +} + +const ModelInfoSupportsItem = ({ + isSupported, + supportsLabel, + doesNotSupportLabel, +}: { + isSupported: boolean + supportsLabel: string + doesNotSupportLabel: string +}) => ( + + + {isSupported ? supportsLabel : doesNotSupportLabel} + +) diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx new file mode 100644 index 0000000000..db306ac7ce --- /dev/null +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -0,0 +1,130 @@ +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import debounce from "debounce" +import { useMemo, useState, useCallback, useEffect } from "react" +import { useMount } from "react-use" +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui" + +import { useExtensionState } from "../../context/ExtensionStateContext" +import { vscode } from "../../utils/vscode" +import { normalizeApiConfiguration } from "./ApiOptions" +import { ModelInfoView } from "./ModelInfoView" + +interface ModelPickerProps { + defaultModelId: string + modelsKey: "glamaModels" | "openRouterModels" + configKey: "glamaModelId" | "openRouterModelId" + infoKey: "glamaModelInfo" | "openRouterModelInfo" + refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" + serviceName: string + serviceUrl: string + recommendedModel: string +} + +export const ModelPicker = ({ + defaultModelId, + modelsKey, + configKey, + infoKey, + refreshMessageType, + serviceName, + serviceUrl, + recommendedModel, +}: ModelPickerProps) => { + const [open, setOpen] = useState(false) + const [value, setValue] = useState(defaultModelId) + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + + const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState() + const modelIds = useMemo(() => Object.keys(models).sort((a, b) => a.localeCompare(b)), [models]) + + const { selectedModelId, selectedModelInfo } = useMemo( + () => normalizeApiConfiguration(apiConfiguration), + [apiConfiguration], + ) + + const onSelect = useCallback( + (modelId: string) => { + const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: models[modelId] } + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) + setValue(modelId) + setOpen(false) + }, + [apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration], + ) + + const debouncedRefreshModels = useMemo( + () => debounce(() => vscode.postMessage({ type: refreshMessageType }), 50), + [refreshMessageType], + ) + + useMount(() => { + debouncedRefreshModels() + return () => debouncedRefreshModels.clear() + }) + + useEffect(() => setValue(selectedModelId), [selectedModelId]) + + return ( + <> +
Model
+ + + + + + + + + No model found. + + {modelIds.map((model) => ( + + {model} + + + ))} + + + + + + {selectedModelId && selectedModelInfo && ( + + )} +

+ The extension automatically fetches the latest list of models available on{" "} + + {serviceName}. + + If you're unsure which model to choose, Roo Code works best with{" "} + onSelect(recommendedModel)}>{recommendedModel}. + You can also try searching "free" for no-cost options currently available. +

+ + ) +} diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 721c45d183..a8243547c6 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -1,12 +1,12 @@ -import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { Fzf } from "fzf" -import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import debounce from "debounce" -import { useRemark } from "react-remark" -import styled from "styled-components" +import { Fzf } from "fzf" +import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react" + import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" import { highlightFzfMatch } from "../../utils/highlight" +import { DropdownWrapper, DropdownList, DropdownItem } from "./styles" const OpenAiModelPicker: React.FC = () => { const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState() @@ -23,6 +23,7 @@ const OpenAiModelPicker: React.FC = () => { ...apiConfiguration, openAiModelId: newModelId, } + setApiConfiguration(apiConfig) onUpdateApiConfig(apiConfig) setSearchTerm(newModelId) @@ -185,12 +186,12 @@ const OpenAiModelPicker: React.FC = () => { )} {isDropdownVisible && ( - + {modelSearchResults.map((item, index) => ( (itemRefs.current[index] = el)} - isSelected={index === selectedIndex} onMouseEnter={() => setSelectedIndex(index)} onClick={() => { handleModelChange(item.id) @@ -213,177 +214,4 @@ export default OpenAiModelPicker // Dropdown -const DropdownWrapper = styled.div` - position: relative; - width: 100%; -` - export const OPENAI_MODEL_PICKER_Z_INDEX = 1_000 - -const DropdownList = styled.div` - position: absolute; - top: calc(100% - 3px); - left: 0; - width: calc(100% - 2px); - max-height: 200px; - overflow-y: auto; - background-color: var(--vscode-dropdown-background); - border: 1px solid var(--vscode-list-activeSelectionBackground); - z-index: ${OPENAI_MODEL_PICKER_Z_INDEX - 1}; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -` - -const DropdownItem = styled.div<{ isSelected: boolean }>` - padding: 5px 10px; - cursor: pointer; - word-break: break-all; - white-space: normal; - - background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; - - &:hover { - background-color: var(--vscode-list-activeSelectionBackground); - } -` - -// Markdown - -const StyledMarkdown = styled.div` - font-family: - var(--vscode-font-family), - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - Oxygen, - Ubuntu, - Cantarell, - "Open Sans", - "Helvetica Neue", - sans-serif; - font-size: 12px; - color: var(--vscode-descriptionForeground); - - p, - li, - ol, - ul { - line-height: 1.25; - margin: 0; - } - - ol, - ul { - padding-left: 1.5em; - margin-left: 0; - } - - p { - white-space: pre-wrap; - } - - a { - text-decoration: none; - } - a { - &:hover { - text-decoration: underline; - } - } -` - -export const ModelDescriptionMarkdown = memo( - ({ - markdown, - key, - isExpanded, - setIsExpanded, - }: { - markdown?: string - key: string - isExpanded: boolean - setIsExpanded: (isExpanded: boolean) => void - }) => { - const [reactContent, setMarkdown] = useRemark() - // const [isExpanded, setIsExpanded] = useState(false) - const [showSeeMore, setShowSeeMore] = useState(false) - const textContainerRef = useRef(null) - const textRef = useRef(null) - - useEffect(() => { - setMarkdown(markdown || "") - }, [markdown, setMarkdown]) - - useEffect(() => { - if (textRef.current && textContainerRef.current) { - const { scrollHeight } = textRef.current - const { clientHeight } = textContainerRef.current - const isOverflowing = scrollHeight > clientHeight - setShowSeeMore(isOverflowing) - // if (!isOverflowing) { - // setIsExpanded(false) - // } - } - }, [reactContent, setIsExpanded]) - - return ( - -
-
- {reactContent} -
- {!isExpanded && showSeeMore && ( -
-
- setIsExpanded(true)}> - See more - -
- )} -
- - ) - }, -) diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index a1761cd618..9111407cd6 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -1,437 +1,15 @@ -import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import debounce from "debounce" -import { Fzf } from "fzf" -import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" -import { useRemark } from "react-remark" -import { useMount } from "react-use" -import styled from "styled-components" +import { ModelPicker } from "./ModelPicker" import { openRouterDefaultModelId } from "../../../../src/shared/api" -import { useExtensionState } from "../../context/ExtensionStateContext" -import { vscode } from "../../utils/vscode" -import { highlightFzfMatch } from "../../utils/highlight" -import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" -const OpenRouterModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState() - const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId) - const [isDropdownVisible, setIsDropdownVisible] = useState(false) - const [selectedIndex, setSelectedIndex] = useState(-1) - const dropdownRef = useRef(null) - const itemRefs = useRef<(HTMLDivElement | null)[]>([]) - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) - const dropdownListRef = useRef(null) - - const handleModelChange = (newModelId: string) => { - // could be setting invalid model id/undefined info but validation will catch it - const apiConfig = { - ...apiConfiguration, - openRouterModelId: newModelId, - openRouterModelInfo: openRouterModels[newModelId], - } - - setApiConfiguration(apiConfig) - onUpdateApiConfig(apiConfig) - setSearchTerm(newModelId) - } - - const { selectedModelId, selectedModelInfo } = useMemo(() => { - return normalizeApiConfiguration(apiConfiguration) - }, [apiConfiguration]) - - useEffect(() => { - if (apiConfiguration?.openRouterModelId && apiConfiguration?.openRouterModelId !== searchTerm) { - setSearchTerm(apiConfiguration?.openRouterModelId) - } - }, [apiConfiguration, searchTerm]) - - const debouncedRefreshModels = useMemo( - () => - debounce(() => { - vscode.postMessage({ type: "refreshOpenRouterModels" }) - }, 50), - [], - ) - - useMount(() => { - debouncedRefreshModels() - - // Cleanup debounced function - return () => { - debouncedRefreshModels.clear() - } - }) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownVisible(false) - } - } - - document.addEventListener("mousedown", handleClickOutside) - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, []) - - const modelIds = useMemo(() => { - return Object.keys(openRouterModels).sort((a, b) => a.localeCompare(b)) - }, [openRouterModels]) - - const searchableItems = useMemo(() => { - return modelIds.map((id) => ({ - id, - html: id, - })) - }, [modelIds]) - - const fzf = useMemo(() => { - return new Fzf(searchableItems, { - selector: (item) => item.html, - }) - }, [searchableItems]) - - const modelSearchResults = useMemo(() => { - if (!searchTerm) return searchableItems - - const searchResults = fzf.find(searchTerm) - return searchResults.map((result) => ({ - ...result.item, - html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"), - })) - }, [searchableItems, searchTerm, fzf]) - - const handleKeyDown = (event: KeyboardEvent) => { - if (!isDropdownVisible) return - - switch (event.key) { - case "ArrowDown": - event.preventDefault() - setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev)) - break - case "ArrowUp": - event.preventDefault() - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)) - break - case "Enter": - event.preventDefault() - if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) { - handleModelChange(modelSearchResults[selectedIndex].id) - setIsDropdownVisible(false) - } - break - case "Escape": - setIsDropdownVisible(false) - setSelectedIndex(-1) - break - } - } - - const hasInfo = useMemo(() => { - return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase()) - }, [modelIds, searchTerm]) - - useEffect(() => { - setSelectedIndex(-1) - if (dropdownListRef.current) { - dropdownListRef.current.scrollTop = 0 - } - }, [searchTerm]) - - useEffect(() => { - if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) { - itemRefs.current[selectedIndex]?.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }) - } - }, [selectedIndex]) - - return ( - <> - -
- - - { - handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase()) - setIsDropdownVisible(true) - }} - onFocus={() => setIsDropdownVisible(true)} - onKeyDown={handleKeyDown} - style={{ width: "100%", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX, position: "relative" }}> - {searchTerm && ( -
{ - handleModelChange("") - setIsDropdownVisible(true) - }} - slot="end" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - height: "100%", - }} - /> - )} - - {isDropdownVisible && ( - - {modelSearchResults.map((item, index) => ( - (itemRefs.current[index] = el)} - isSelected={index === selectedIndex} - onMouseEnter={() => setSelectedIndex(index)} - onClick={() => { - handleModelChange(item.id) - setIsDropdownVisible(false) - }} - dangerouslySetInnerHTML={{ - __html: item.html, - }} - /> - ))} - - )} - -
- - {hasInfo ? ( - - ) : ( -

- The extension automatically fetches the latest list of models available on{" "} - - OpenRouter. - - If you're unsure which model to choose, Roo Code works best with{" "} - handleModelChange("anthropic/claude-3.5-sonnet:beta")}> - anthropic/claude-3.5-sonnet:beta. - - You can also try searching "free" for no-cost options currently available. -

- )} - - ) -} - -export default OpenRouterModelPicker - -// Dropdown - -const DropdownWrapper = styled.div` - position: relative; - width: 100%; -` - -export const OPENROUTER_MODEL_PICKER_Z_INDEX = 1_000 - -const DropdownList = styled.div` - position: absolute; - top: calc(100% - 3px); - left: 0; - width: calc(100% - 2px); - max-height: 200px; - overflow-y: auto; - background-color: var(--vscode-dropdown-background); - border: 1px solid var(--vscode-list-activeSelectionBackground); - z-index: ${OPENROUTER_MODEL_PICKER_Z_INDEX - 1}; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -` - -const DropdownItem = styled.div<{ isSelected: boolean }>` - padding: 5px 10px; - cursor: pointer; - word-break: break-all; - white-space: normal; - - background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; - - &:hover { - background-color: var(--vscode-list-activeSelectionBackground); - } -` - -// Markdown - -const StyledMarkdown = styled.div` - font-family: - var(--vscode-font-family), - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - Oxygen, - Ubuntu, - Cantarell, - "Open Sans", - "Helvetica Neue", - sans-serif; - font-size: 12px; - color: var(--vscode-descriptionForeground); - - p, - li, - ol, - ul { - line-height: 1.25; - margin: 0; - } - - ol, - ul { - padding-left: 1.5em; - margin-left: 0; - } - - p { - white-space: pre-wrap; - } - - a { - text-decoration: none; - } - a { - &:hover { - text-decoration: underline; - } - } -` - -export const ModelDescriptionMarkdown = memo( - ({ - markdown, - key, - isExpanded, - setIsExpanded, - }: { - markdown?: string - key: string - isExpanded: boolean - setIsExpanded: (isExpanded: boolean) => void - }) => { - const [reactContent, setMarkdown] = useRemark() - // const [isExpanded, setIsExpanded] = useState(false) - const [showSeeMore, setShowSeeMore] = useState(false) - const textContainerRef = useRef(null) - const textRef = useRef(null) - - useEffect(() => { - setMarkdown(markdown || "") - }, [markdown, setMarkdown]) - - useEffect(() => { - if (textRef.current && textContainerRef.current) { - const { scrollHeight } = textRef.current - const { clientHeight } = textContainerRef.current - const isOverflowing = scrollHeight > clientHeight - setShowSeeMore(isOverflowing) - // if (!isOverflowing) { - // setIsExpanded(false) - // } - } - }, [reactContent, setIsExpanded]) - - return ( - -
-
- {reactContent} -
- {!isExpanded && showSeeMore && ( -
-
- setIsExpanded(true)}> - See more - -
- )} -
- {/* {isExpanded && showSeeMore && ( -
setIsExpanded(false)}> - See less -
- )} */} - - ) - }, +export const OpenRouterModelPicker = () => ( + ) diff --git a/webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx b/webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx new file mode 100644 index 0000000000..4e7c67c187 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx @@ -0,0 +1,86 @@ +// cd webview-ui && npx jest src/components/settings/__tests__/ModelPicker.test.ts + +import { screen, fireEvent, render } from "@testing-library/react" +import { act } from "react" +import { ModelPicker } from "../ModelPicker" +import { useExtensionState } from "../../../context/ExtensionStateContext" + +jest.mock("../../../context/ExtensionStateContext", () => ({ + useExtensionState: jest.fn(), +})) + +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = MockResizeObserver + +Element.prototype.scrollIntoView = jest.fn() + +describe("ModelPicker", () => { + const mockOnUpdateApiConfig = jest.fn() + const mockSetApiConfiguration = jest.fn() + + const defaultProps = { + defaultModelId: "model1", + modelsKey: "glamaModels" as const, + configKey: "glamaModelId" as const, + infoKey: "glamaModelInfo" as const, + refreshMessageType: "refreshGlamaModels" as const, + serviceName: "Test Service", + serviceUrl: "https://test.service", + recommendedModel: "recommended-model", + } + + const mockModels = { + model1: { name: "Model 1", description: "Test model 1" }, + model2: { name: "Model 2", description: "Test model 2" }, + } + + beforeEach(() => { + jest.clearAllMocks() + ;(useExtensionState as jest.Mock).mockReturnValue({ + apiConfiguration: {}, + setApiConfiguration: mockSetApiConfiguration, + glamaModels: mockModels, + onUpdateApiConfig: mockOnUpdateApiConfig, + }) + }) + + it("calls onUpdateApiConfig when a model is selected", async () => { + await act(async () => { + render() + }) + + await act(async () => { + // Open the popover by clicking the button. + const button = screen.getByRole("combobox") + fireEvent.click(button) + }) + + // Wait for popover to open and animations to complete. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + await act(async () => { + // Find and click the model item by its value. + const modelItem = screen.getByRole("option", { name: "model2" }) + fireEvent.click(modelItem) + }) + + // Verify the API config was updated. + expect(mockSetApiConfiguration).toHaveBeenCalledWith({ + glamaModelId: "model2", + glamaModelInfo: mockModels["model2"], + }) + + // Verify onUpdateApiConfig was called with the new config. + expect(mockOnUpdateApiConfig).toHaveBeenCalledWith({ + glamaModelId: "model2", + glamaModelInfo: mockModels["model2"], + }) + }) +}) diff --git a/webview-ui/src/components/settings/styles.ts b/webview-ui/src/components/settings/styles.ts new file mode 100644 index 0000000000..85b50579fb --- /dev/null +++ b/webview-ui/src/components/settings/styles.ts @@ -0,0 +1,80 @@ +import styled from "styled-components" + +export const DROPDOWN_Z_INDEX = 1_000 + +export const DropdownWrapper = styled.div` + position: relative; + width: 100%; +` + +export const DropdownList = styled.div<{ $zIndex: number }>` + position: absolute; + top: calc(100% - 3px); + left: 0; + width: calc(100% - 2px); + max-height: 200px; + overflow-y: auto; + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-list-activeSelectionBackground); + z-index: ${({ $zIndex }) => $zIndex}; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +` + +export const DropdownItem = styled.div<{ $selected: boolean }>` + padding: 5px 10px; + cursor: pointer; + word-break: break-all; + white-space: normal; + + background-color: ${({ $selected }) => ($selected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; + + &:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } +` + +export const StyledMarkdown = styled.div` + font-family: + var(--vscode-font-family), + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + font-size: 12px; + color: var(--vscode-descriptionForeground); + + p, + li, + ol, + ul { + line-height: 1.25; + margin: 0; + } + + ol, + ul { + padding-left: 1.5em; + margin-left: 0; + } + + p { + white-space: pre-wrap; + } + + a { + text-decoration: none; + } + a { + &:hover { + text-decoration: underline; + } + } +` diff --git a/webview-ui/src/components/ui/button.tsx b/webview-ui/src/components/ui/button.tsx index 370ff4a19f..e78a06b4fb 100644 --- a/webview-ui/src/components/ui/button.tsx +++ b/webview-ui/src/components/ui/button.tsx @@ -10,11 +10,14 @@ const buttonVariants = cva( variants: { variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: "border border-input bg-foreground shadow-sm hover:bg-foreground/80", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + outline: + "border border-vscode-dropdown-border bg-vscode-background shadow-sm hover:border-vscode-dropdown-border/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", + destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + combobox: + "bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border", }, size: { default: "h-7 px-3", diff --git a/webview-ui/src/components/ui/command.tsx b/webview-ui/src/components/ui/command.tsx index fb8011893d..9580351139 100644 --- a/webview-ui/src/components/ui/command.tsx +++ b/webview-ui/src/components/ui/command.tsx @@ -38,7 +38,7 @@ const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -
+
, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) CommandSeparator.displayName = CommandPrimitive.Separator.displayName @@ -104,7 +108,7 @@ const CommandItem = React.forwardRef< { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(price) +}