diff --git a/package-lock.json b/package-lock.json index 966a06e81..bc0458914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.32.1", + "@floating-ui/react": "^0.27.3", "@google/generative-ai": "^0.21.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -1551,6 +1552,65 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz", + "integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react/node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@google/generative-ai": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz", diff --git a/package.json b/package.json index c8f0159b4..a4ee38bb4 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.32.1", + "@floating-ui/react": "^0.27.3", "@google/generative-ai": "^0.21.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", diff --git a/src/vs/platform/void/common/voidSettingsService.ts b/src/vs/platform/void/common/voidSettingsService.ts index c92810abe..811f43371 100644 --- a/src/vs/platform/void/common/voidSettingsService.ts +++ b/src/vs/platform/void/common/voidSettingsService.ts @@ -81,7 +81,7 @@ let _computeModelOptions = (settingsOfProvider: SettingsOfProvider) => { const defaultState = () => { const d: VoidSettingsState = { settingsOfProvider: deepClone(defaultSettingsOfProvider), - modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null }, + modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'FastApply': null }, globalSettings: deepClone(defaultGlobalSettings), _modelOptions: _computeModelOptions(defaultSettingsOfProvider), // computed } @@ -137,6 +137,11 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { ...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName)) ] } + }, + modelSelectionOfFeature: { + // A HACK BECAUSE WE ADDED FastApply + ...{ 'FastApply': null }, + ...readS.modelSelectionOfFeature, } } diff --git a/src/vs/platform/void/common/voidSettingsTypes.ts b/src/vs/platform/void/common/voidSettingsTypes.ts index f0f2624a2..43a29f5b7 100644 --- a/src/vs/platform/void/common/voidSettingsTypes.ts +++ b/src/vs/platform/void/common/voidSettingsTypes.ts @@ -432,14 +432,22 @@ export const modelSelectionsEqual = (m1: ModelSelection, m2: ModelSelection) => } // this is a state -export type ModelSelectionOfFeature = { - 'Ctrl+L': ModelSelection | null, - 'Ctrl+K': ModelSelection | null, - 'Autocomplete': ModelSelection | null, -} +export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete', 'FastApply'] as const +export type ModelSelectionOfFeature = Record<(typeof featureNames)[number], ModelSelection | null> export type FeatureName = keyof ModelSelectionOfFeature -export const featureNames = ['Ctrl+L', 'Ctrl+K', 'Autocomplete'] as const +export const displayInfoOfFeatureName = (featureName: FeatureName) => { + if (featureName === 'Autocomplete') + return 'Autocomplete' + else if (featureName === 'Ctrl+K') + return 'Quick Edit' + else if (featureName === 'Ctrl+L') + return 'Sidebar Chat' + else if (featureName === 'FastApply') + return 'Fast Apply' + else + throw new Error(`Feature Name ${featureName} not allowed`) +} diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx index 43abd5b1d..4c2cbea48 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx @@ -13,10 +13,9 @@ export const BlockCode = ({ buttonsOnHover, ...codeEditorProps }: { buttonsOnHov return ( <> -
+
{buttonsOnHover === null ? null : ( -
+
{buttonsOnHover}
diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 8cb4864fb..0ea5febba 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -436,7 +436,7 @@ const ChatBubble_ = ({ isEditMode, isLoading, children, role }: { role: ChatMess className={` relative ${isEditMode ? 'px-2 w-full max-w-full' - : role === 'user' ? `px-2 self-end w-fit max-w-full` + : role === 'user' ? `px-2 self-end w-fit max-w-full whitespace-pre-wrap` // user words should be pre : role === 'assistant' ? `px-2 self-start w-full max-w-full` : '' } `} @@ -444,7 +444,7 @@ const ChatBubble_ = ({ isEditMode, isLoading, children, role }: { role: ChatMess
{ ) }, [previousMessages]) - return
- {/* thread selector */} -
- -
+ +
- {/* previous messages + current stream */} - - {/* previous messages */} - {prevMessagesHTML} - {/* message stream */} - + const messagesHTML = + {/* previous messages */} + {prevMessagesHTML} + {/* message stream */} + - {/* error message */} - {latestError === undefined ? null : -
- { chatThreadsService.dismissStreamError(currentThread.id) }} - showDismiss={true} - /> - { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' /> -
- } + {/* error message */} + {latestError === undefined ? null : +
+ { chatThreadsService.dismissStreamError(currentThread.id) }} + showDismiss={true} + /> - + { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' /> +
+ } +
- {/* input box */} -
0 ? 'absolute bottom-0' : ''}`} + const inputBox =
0 ? 'absolute bottom-0' : ''}`} + > +
{ + textAreaRef.current?.focus() + }} > + {/* top row */} + <> + {/* selections */} + + + + {/* middle row */} +
+ + {/* text input */} + { setInstructionsAreEmpty(!newStr) }, [setInstructionsAreEmpty])} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + onSubmit() + } + }} + ref={textAreaRef} + fnsRef={textAreaFnsRef} + multiline={true} + /> +
+ + {/* bottom row */}
{ - textAreaRef.current?.focus() - }} + className='flex flex-row justify-between items-end gap-1' > - {/* top row */} - <> - {/* selections */} - - - - {/* middle row */} -
- - {/* text input */} - { setInstructionsAreEmpty(!newStr) }, [setInstructionsAreEmpty])} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - onSubmit() - } - }} - ref={textAreaRef} - fnsRef={textAreaFnsRef} - multiline={true} - /> + {/* submit options */} +
+
- {/* bottom row */} -
- {/* submit options */} -
- -
+ {/* submit / stop button */} + {isStreaming ? + // stop button + + : + // submit button (up arrow) + + } +
+
+
- {/* submit / stop button */} - {isStreaming ? - // stop button - - : - // submit button (up arrow) - - } -
+ return
+ {threadSelector} + {messagesHTML} -
-
-
+ {inputBox} + +
} diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index b21df36fc..357640c08 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -15,6 +15,7 @@ import { useAccessor } from './services.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { inputBackground, inputForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; +import { useFloating, autoUpdate, offset, flip, shift, size, autoPlacement } from '@floating-ui/react'; // type guard @@ -296,6 +297,7 @@ export const VoidCheckBox = ({ label, value, onClick, className }: { label: stri } + export const VoidCustomSelectBox = ({ options, selectedOption: selectedOption_, @@ -306,7 +308,6 @@ export const VoidCustomSelectBox = ({ className, arrowTouchesText = true, matchInputWidth = false, - isMenuPositionFixed = true, gap = 0, }: { options: T[]; @@ -318,18 +319,58 @@ export const VoidCustomSelectBox = ({ className?: string; arrowTouchesText?: boolean; matchInputWidth?: boolean; - isMenuPositionFixed?: boolean; gap?: number; }) => { const [isOpen, setIsOpen] = useState(false); - const [readyToShow, setReadyToShow] = useState(false); - const [position, setPosition] = useState({ top: 0, left: 0, width: 0 }); - const containerRef = useRef(null); - const buttonRef = useRef(null); - const measureRef = useRef(null); - - - // if the selected option is null, use the 0th option as the selected, and set the option to options[0] + const measureRef = useRef(null); + + // Replace manual positioning with floating-ui + const { + x, + y, + strategy, + refs, + middlewareData, + update + } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement:'bottom-start', + + middleware: [ + offset(gap), + flip({ + boundary: document.body, + padding: 8 + }), + shift({ + boundary: document.body, + padding: 8, + }), + size({ + apply({ availableHeight, elements, rects }) { + const maxHeight = Math.min(availableHeight) + + Object.assign(elements.floating.style, { + maxHeight: `${maxHeight}px`, + overflowY: 'auto', + // Ensure the width isn't constrained by the parent + width: `${Math.max( + rects.reference.width, + measureRef.current?.offsetWidth ?? 0 + )}px` + }); + }, + padding: 8, + // Use viewport as boundary instead of any parent element + boundary: document.body, + }), + ], + whileElementsMounted: autoUpdate, + strategy:'fixed', + }); + + // if the selected option is null, use the 0th option useEffect(() => { if (!options[0]) return if (!selectedOption_) { @@ -338,84 +379,33 @@ export const VoidCustomSelectBox = ({ }, [selectedOption_, options]) const selectedOption = !selectedOption_ ? options[0] : selectedOption_ - - const updatePosition = useCallback(() => { - if (!buttonRef.current || !containerRef.current || !measureRef.current) return; - - const buttonRect = buttonRef.current.getBoundingClientRect(); - const containerRect = containerRef.current.getBoundingClientRect(); - const containerWidth = containerRef.current.offsetWidth; - const viewportHeight = window.innerHeight; - const spaceBelow = viewportHeight - buttonRect.bottom; - const spaceNeeded = options.length * 28; - const showAbove = spaceBelow < spaceNeeded && buttonRect.top > spaceBelow; - - // Calculate the menu width - let menuWidth = matchInputWidth ? containerWidth : buttonRect.width; - - // If not matchInputWidth, calculate content width from measurement div - if (!matchInputWidth) { - const contentWidth = measureRef.current.offsetWidth; - menuWidth = Math.max(buttonRect.width, contentWidth); - } - - if (isMenuPositionFixed) { - // Fixed positioning (relative to viewport) - setPosition({ - top: showAbove - ? buttonRect.top - spaceNeeded - : buttonRect.bottom + gap, - left: buttonRect.left, - width: menuWidth, - }); - } else { - // Absolute positioning (relative to parent container) - setPosition({ - top: showAbove - ? -(spaceNeeded + gap) - : buttonRect.height + gap, - left: 0, - width: menuWidth, - }); - } - - setReadyToShow(true); - }, [gap, matchInputWidth, options.length, isMenuPositionFixed]); - + // Handle clicks outside useEffect(() => { - if (isOpen) { - setReadyToShow(false); - updatePosition(); - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); - - return () => { - window.removeEventListener('scroll', updatePosition, true); - window.removeEventListener('resize', updatePosition); - }; - } else { - setReadyToShow(false); - } - }, [isOpen, updatePosition]); + if (!isOpen) return; - useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + const target = event.target as Node; + const floating = refs.floating.current; + const reference = refs.reference.current; + + // Check if reference is an HTML element before using contains + const isReferenceHTMLElement = reference && 'contains' in reference; + + if ( + floating && + (!isReferenceHTMLElement || !reference.contains(target)) && + !floating.contains(target) + ) { setIsOpen(false); } }; - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, refs.floating, refs.reference]); return ( -
+
{/* Hidden measurement div */}
({ {/* Select Button */} {/* Dropdown Menu */} - {isOpen && readyToShow && ( + {isOpen && (
{options.map((option) => { diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx index 67a9a9c86..d6ca33b28 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx @@ -42,7 +42,7 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat getOptionsEqual={(a, b) => optionsEqual([a], [b])} className={`text-xs text-void-fg-3 px-1`} matchInputWidth={false} - isMenuPositionFixed={featureName === 'Ctrl+K' ? false : true} + // isMenuPositionFixed={false} /> } // const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], featureName: FeatureName }) => { diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index 62de3bed8..7c5751d70 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js' -import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName } from '../../../../../../../platform/void/common/voidSettingsTypes.js' +import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName } from '../../../../../../../platform/void/common/voidSettingsTypes.js' import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js' import { VoidButton, VoidCheckBox, VoidCustomSelectBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js' import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js' @@ -14,7 +14,7 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js' import { isWindows, isLinux, isMacintosh } from '../../../../../../../base/common/platform.js' import { URI } from '../../../../../../../base/common/uri.js' import { env } from '../../../../../../../base/common/process.js' -import { WarningBox } from './ModelDropdown.js' +import { WarningBox, ModelDropdown } from './ModelDropdown.js' import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js' const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => { @@ -392,7 +392,7 @@ export const AIInstructionsBox = () => { const voidSettingsService = accessor.get('IVoidSettingsService') const voidSettingsState = useSettingsState() return {
+
+

Model Selection

+ {featureNames.map(featureName => +
+

{displayInfoOfFeatureName(featureName)}

+ +
+ )} +
}