From c50af7226ccda78a5141888b95a3bb1e8fcd6122 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 19 Jan 2025 16:33:15 -0800 Subject: [PATCH] Show API provider as popup --- webview-ui/src/App.tsx | 15 +- webview-ui/src/components/chat/ChatRow.tsx | 2 +- .../src/components/chat/ChatTextArea.tsx | 262 ++++++++++++++++-- .../src/components/settings/ApiOptions.tsx | 34 ++- .../settings/OpenRouterModelPicker.tsx | 9 +- .../src/components/settings/SettingsView.tsx | 4 +- 6 files changed, 271 insertions(+), 55 deletions(-) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 954ca8dd8d..f06453aca9 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -15,7 +15,6 @@ const AppContent = () => { const [showHistory, setShowHistory] = useState(false) const [showMcp, setShowMcp] = useState(false) const [showAnnouncement, setShowAnnouncement] = useState(false) - const [showAdvisorModelSettings, setShowAdvisorModelSettings] = useState(false) const handleMessage = useCallback((e: MessageEvent) => { const message: ExtensionMessage = e.data @@ -24,36 +23,26 @@ const AppContent = () => { switch (message.action!) { case "settingsButtonClicked": setShowSettings(true) - setShowAdvisorModelSettings(false) setShowHistory(false) setShowMcp(false) break case "historyButtonClicked": setShowSettings(false) - setShowAdvisorModelSettings(false) setShowHistory(true) setShowMcp(false) break case "mcpButtonClicked": setShowSettings(false) - setShowAdvisorModelSettings(false) setShowHistory(false) setShowMcp(true) break case "chatButtonClicked": setShowSettings(false) - setShowAdvisorModelSettings(false) setShowHistory(false) setShowMcp(false) break } break - case "openAdvisorModelSettings": - setShowSettings(true) - setShowAdvisorModelSettings(true) - setShowHistory(false) - setShowMcp(false) - break } }, []) @@ -76,9 +65,7 @@ const AppContent = () => { ) : ( <> - {showSettings && ( - setShowSettings(false)} showAdvisorModelSettings={showAdvisorModelSettings} /> - )} + {showSettings && setShowSettings(false)} />} {showHistory && setShowHistory(false)} />} {showMcp && setShowMcp(false)} />} {/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index bc26bb152b..10b4505971 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -793,7 +793,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi vscode.postMessage({ type: "openAdvisorModelSettings" })}> - in Settings. + in API Settings. diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index b0fe60f04a..735bc5c668 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -14,8 +14,12 @@ import ContextMenu from "./ContextMenu" import Thumbnails from "../common/Thumbnails" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import styled from "styled-components" -import { useWindowSize } from "react-use" +import { useEvent, useWindowSize } from "react-use" import { vscode } from "../../utils/vscode" +import ApiOptions from "../settings/ApiOptions" +import { useClickAway } from "react-use" +import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" +import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" interface ChatTextAreaProps { inputValue: string @@ -51,13 +55,11 @@ const SwitchContainer = styled.div<{ disabled: boolean }>` border: 1px solid var(--vscode-input-border); border-radius: 12px; overflow: hidden; - position: absolute; - right: 15px; cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; opacity: ${(props) => (props.disabled ? 0.5 : 1)}; transform: scale(0.85); transform-origin: right center; - flex-shrink: 0; + margin-left: -10px; // compensate for the transform so flex spacing works ` const Slider = styled.div<{ isChat: boolean }>` @@ -69,33 +71,117 @@ const Slider = styled.div<{ isChat: boolean }>` transform: translateX(${(props) => (props.isChat ? "100%" : "0%")}); ` +const ButtonGroup = styled.div` + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +` + const ButtonContainer = styled.div` display: flex; align-items: center; gap: 3px; font-size: 10px; white-space: nowrap; + min-width: 0; + width: 100%; ` -const ACTUAL_SWITCH_WIDTH = 90 -const SWITCH_WIDTH = ACTUAL_SWITCH_WIDTH * 0.85 // Account for the 0.85 scale transform -const CONTEXT_BUTTON_WIDTH = 60 -const IMAGES_BUTTON_WIDTH = 80 -const CONTAINER_PADDING = 30 // 15px left + 15px right -const TOTAL_WIDTH = SWITCH_WIDTH + 4 + CONTEXT_BUTTON_WIDTH + IMAGES_BUTTON_WIDTH + CONTAINER_PADDING - const ControlsContainer = styled.div` display: flex; align-items: center; - margin-top: -3px; - position: relative; + justify-content: space-between; + margin-top: -5px; padding: 0px 15px 5px 15px; ` -const ButtonGroup = styled.div` +const ModelSelectorTooltip = styled.div` + position: fixed; + bottom: calc(100% + 9px); + left: 15px; + right: 15px; + background: ${CODE_BLOCK_BG_COLOR}; + border: 1px solid var(--vscode-editorGroup-border); + padding: 12px; + border-radius: 3px; + z-index: 1000; + max-height: calc(100vh - 100px); + overflow-y: auto; + overscroll-behavior: contain; + + // Add invisible padding for hover zone + &::before { + content: ""; + position: fixed; + bottom: ${(props) => `calc(100vh - ${props.menuPosition}px - 2px)`}; + left: 0; + right: 0; + height: 8px; + } + + // Arrow pointing down + &::after { + content: ""; + position: fixed; + bottom: ${(props) => `calc(100vh - ${props.menuPosition}px)`}; + right: ${(props) => props.arrowPosition}px; + width: 10px; + height: 10px; + background: ${CODE_BLOCK_BG_COLOR}; + border-right: 1px solid var(--vscode-editorGroup-border); + border-bottom: 1px solid var(--vscode-editorGroup-border); + transform: rotate(45deg); + z-index: -1; + } +` + +const ModelContainer = styled.div` + position: relative; + display: flex; + flex: 1; + min-width: 0; +` + +const ModelDisplayButton = styled.a<{ isActive?: boolean }>` + padding: 0px 0px; + height: 20px; + width: 100%; + min-width: 0; + cursor: pointer; + text-decoration: ${(props) => (props.isActive ? "underline" : "none")}; + color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")}; display: flex; align-items: center; - gap: 4px; + font-size: 10px; + outline: none; + user-select: none; + + &:hover, + &:focus { + color: var(--vscode-foreground); + text-decoration: underline; + outline: none; + } + + &:active { + color: var(--vscode-foreground); + text-decoration: underline; + outline: none; + } + + &:focus-visible { + outline: none; + } +` + +const ModelButtonContent = styled.div` + width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; ` const ChatTextArea = forwardRef( @@ -114,7 +200,7 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, chatSettings } = useExtensionState() + const { filePaths, chatSettings, apiConfiguration } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) @@ -129,8 +215,13 @@ const ChatTextArea = forwardRef( const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) const contextMenuContainerRef = useRef(null) - const { width: windowWidth } = useWindowSize() - const showButtonText = windowWidth - CONTAINER_PADDING > TOTAL_WIDTH - CONTAINER_PADDING + const [showModelSelector, setShowModelSelector] = useState(false) + const [showModelSelectorWithAdvisor, setShowModelSelectorWithAdvisor] = useState(false) + const modelSelectorRef = useRef(null) + const { width: viewportWidth, height: viewportHeight } = useWindowSize() + const buttonRef = useRef(null) + const [arrowPosition, setArrowPosition] = useState(0) + const [menuPosition, setMenuPosition] = useState(0) const queryItems = useMemo(() => { return [ @@ -538,6 +629,83 @@ const ChatTextArea = forwardRef( updateHighlights() }, [inputValue, textAreaDisabled, handleInputChange, updateHighlights]) + // Add click away handler + useClickAway(modelSelectorRef, () => { + setShowModelSelector(false) + }) + + // Get model display name + const modelDisplayName = useMemo(() => { + const unknownModel = "unknown" + if (!apiConfiguration) return unknownModel + switch (apiConfiguration.apiProvider) { + case "anthropic": + return `anthropic:${apiConfiguration.apiModelId || unknownModel}` + case "openai": + return `openai:${apiConfiguration.openAiModelId || unknownModel}` + case "openrouter": + return `openrouter:${apiConfiguration.openRouterModelId || unknownModel}` + case "bedrock": + return `bedrock:${apiConfiguration.apiModelId || unknownModel}` + case "vertex": + return `vertex:${apiConfiguration.apiModelId || unknownModel}` + case "ollama": + return `ollama:${apiConfiguration.ollamaModelId || unknownModel}` + case "lmstudio": + return `lmstudio:${apiConfiguration.lmStudioModelId || unknownModel}` + case "gemini": + return `gemini:${apiConfiguration.apiModelId || unknownModel}` + case "openai-native": + return `openai-native:${apiConfiguration.apiModelId || unknownModel}` + case "deepseek": + return `deepseek:${apiConfiguration.apiModelId || unknownModel}` + case "mistral": + return `mistral:${apiConfiguration.apiModelId || unknownModel}` + case "vscode-lm": + return `vscode-lm:${apiConfiguration.vsCodeLmModelSelector ? `${apiConfiguration.vsCodeLmModelSelector.vendor ?? ""}/${apiConfiguration.vsCodeLmModelSelector.family ?? ""}` : unknownModel}` + default: + return unknownModel + } + }, [apiConfiguration]) + + // Calculate arrow position and menu position based on button location + useEffect(() => { + if (showModelSelector && buttonRef.current) { + const buttonRect = buttonRef.current.getBoundingClientRect() + const buttonCenter = buttonRect.left + buttonRect.width / 2 + + // Calculate distance from right edge of viewport using viewport coordinates + const rightPosition = document.documentElement.clientWidth - buttonCenter - 5 + + setArrowPosition(rightPosition) + setMenuPosition(buttonRect.top + 1) // Added +1 to move menu down by 1px + } + }, [showModelSelector, viewportWidth, viewportHeight]) + + // Reset advisor settings when model selector is closed + useEffect(() => { + if (!showModelSelector) { + setShowModelSelectorWithAdvisor(false) + // Reset any active styling by blurring the button + const button = buttonRef.current?.querySelector("a") + if (button) { + button.blur() + } + } + }, [showModelSelector]) + + const handleMessage = useCallback((e: MessageEvent) => { + const message: ExtensionMessage = e.data + switch (message.type) { + case "openAdvisorModelSettings": + setShowModelSelector(true) + setShowModelSelectorWithAdvisor(true) + break + } + }, []) + + useEvent("message", handleMessage) + return (
( aria-label="Add Context" disabled={textAreaDisabled} onClick={handleContextButtonClick} - style={{ padding: "0px 0px", height: "20px", marginTop: -1 }}> + style={{ padding: "0px 0px", height: "20px" }}> - @ - {showButtonText && Context} + @ + {/* {showButtonText && Context} */} @@ -739,17 +907,47 @@ const ChatTextArea = forwardRef( onSelectImages() } }} - style={{ - padding: "0px 0px", - height: "20px", - opacity: shouldDisableImages ? 0.5 : 1, - cursor: shouldDisableImages ? "not-allowed" : undefined, - }}> + style={{ padding: "0px 0px", height: "20px" }}> - - {showButtonText && Add images} + + {/* {showButtonText && Images} */} + + +
+ setShowModelSelector(!showModelSelector)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setShowModelSelector(!showModelSelector) + } + }} + tabIndex={0}> + {modelDisplayName} + +
+ {showModelSelector && ( + + + + )} +
@@ -763,4 +961,10 @@ const ChatTextArea = forwardRef( }, ) +// Update TypeScript interface for styled-component props +interface ModelSelectorTooltipProps { + arrowPosition: number + menuPosition: number +} + export default ChatTextArea diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 0aace65eb4..b2073a8df6 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -50,6 +50,7 @@ interface ApiOptionsProps { modelIdErrorMessage?: string advisorModelIdErrorMessage?: string showAdvisorModelSettings?: boolean + isPopup?: boolean } const TabPanel = ({ children, isSelected }: { children: React.ReactNode; isSelected: boolean }) => { @@ -88,12 +89,28 @@ const TabButton = ({ ) } +// This is necessary to ensure dropdown opens downward, important for when this is used in popup +const DROPDOWN_Z_INDEX = 1001 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index + +const DropdownContainer = styled.div` + position: relative; + z-index: ${DROPDOWN_Z_INDEX}; + + // Force dropdowns to open downward + & vscode-dropdown::part(listbox) { + position: absolute !important; + top: 100% !important; + bottom: auto !important; + } +` + const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, advisorModelIdErrorMessage, showAdvisorModelSettings, + isPopup, }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) @@ -190,8 +207,8 @@ const ApiOptions = ({ } return ( -
-
+
+ @@ -202,7 +219,6 @@ const ApiOptions = ({ style={{ minWidth: 130, position: "relative", - zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1, }}> OpenRouter Anthropic @@ -217,7 +233,7 @@ const ApiOptions = ({ LM Studio Ollama -
+ {selectedProvider === "anthropic" && (
@@ -865,6 +881,7 @@ const ApiOptions = ({ modelInfo={selectedModelInfo} isDescriptionExpanded={isDescriptionExpanded} setIsDescriptionExpanded={setIsDescriptionExpanded} + isPopup={isPopup} /> )} @@ -906,7 +923,9 @@ const ApiOptions = ({ {createDropdown(anthropicModels, "base")}
)} - {selectedProvider === "openrouter" && } + {selectedProvider === "openrouter" && ( + + )} {modelIdErrorMessage && (

)} {selectedProvider === "openrouter" && ( - + )} {advisorModelIdErrorMessage && (

void + isPopup?: boolean }) => { const isGemini = Object.keys(geminiModels).includes(selectedModelId) @@ -987,6 +1008,7 @@ export const ModelInfoView = ({ markdown={modelInfo.description} isExpanded={isDescriptionExpanded} setIsExpanded={setIsDescriptionExpanded} + isPopup={isPopup} /> ), = ({ modelType }) => { +const OpenRouterModelPicker: React.FC = ({ modelType, isPopup }) => { const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState() const [searchTerm, setSearchTerm] = useState( modelType === "advisor" @@ -230,6 +232,7 @@ const OpenRouterModelPicker: React.FC = ({ modelType } isDescriptionExpanded={isDescriptionExpanded} setIsDescriptionExpanded={setIsDescriptionExpanded} + isPopup={isPopup} /> ) : (

void + isPopup?: boolean }) => { const [reactContent, setMarkdown] = useRemark() // const [isExpanded, setIsExpanded] = useState(false) @@ -434,7 +439,7 @@ export const ModelDescriptionMarkdown = memo( fontSize: "inherit", paddingRight: 0, paddingLeft: 3, - backgroundColor: "var(--vscode-sideBar-background)", + backgroundColor: isPopup ? CODE_BLOCK_BG_COLOR : "var(--vscode-sideBar-background)", }} onClick={() => setIsExpanded(true)}> See more diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 921f311c56..91e9d136c8 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -8,11 +8,10 @@ import ApiOptions from "./ApiOptions" const IS_DEV = false // FIXME: use flags when packaging type SettingsViewProps = { - showAdvisorModelSettings: boolean onDone: () => void } -const SettingsView = ({ showAdvisorModelSettings, onDone }: SettingsViewProps) => { +const SettingsView = ({ onDone }: SettingsViewProps) => { const { apiConfiguration, version, customInstructions, setCustomInstructions, openRouterModels } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) @@ -94,7 +93,6 @@ const SettingsView = ({ showAdvisorModelSettings, onDone }: SettingsViewProps) =