From 992f9f1cf4f20967a17c598eab32dc33d1be7600 Mon Sep 17 00:00:00 2001 From: liangfung <1098486429@qq.com> Date: Mon, 6 Nov 2023 23:55:19 +0800 Subject: [PATCH] fix: prepare for submitting prompt --- ee/tabby-ui/components/prompt-form.tsx | 185 +++++++++++++++++-------- ee/tabby-ui/components/ui/combobox.tsx | 62 +++++++-- ee/tabby-ui/components/ui/icons.tsx | 21 ++- ee/tabby-ui/lib/utils.ts | 15 -- ee/tabby-ui/package.json | 4 +- ee/tabby-ui/yarn.lock | 19 ++- 6 files changed, 211 insertions(+), 95 deletions(-) diff --git a/ee/tabby-ui/components/prompt-form.tsx b/ee/tabby-ui/components/prompt-form.tsx index cf52608..df06121 100644 --- a/ee/tabby-ui/components/prompt-form.tsx +++ b/ee/tabby-ui/components/prompt-form.tsx @@ -1,7 +1,12 @@ import { UseChatHelpers } from 'ai/react' import * as React from 'react' +import useSWR from 'swr' import { Button, buttonVariants } from '@/components/ui/button' -import { IconArrowElbow, IconEdit } from '@/components/ui/icons' +import { + IconArrowElbow, + IconEdit, + IconSymbolFunction +} from '@/components/ui/icons' import { Tooltip, TooltipContent, @@ -16,11 +21,11 @@ import { } from '@/components/ui/combobox' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' -import { cn, getSearchCompletionQueryName } from '@/lib/utils' -import useSWR from 'swr' +import { cn } from '@/lib/utils' import fetcher from '@/lib/tabby-fetcher' -import { debounce, has } from 'lodash' -import { ISearchHit, SearchReponse } from '@/lib/types' +import { debounce, has } from 'lodash-es' +import type { ISearchHit, SearchReponse } from '@/lib/types' +import { lightfair } from 'react-syntax-highlighter/dist/esm/styles/hljs' export interface PromptProps extends Pick { @@ -39,6 +44,12 @@ export function PromptForm({ string | null >(null) const latestFetchKey = React.useRef('') + const inputRef = React.useRef(null) + const nextInputSelectionRange = React.useRef<[number, number]>() + const [options, setOptions] = React.useState([]) + const [selectedCompletionsMap, setSelectedCompletionsMap] = React.useState< + Record + >({}) useSWR(queryCompletionUrl, fetcher, { revalidateOnFocus: false, @@ -50,14 +61,24 @@ export function PromptForm({ } }) - const [options, setOptions] = React.useState([]) - const onSearch = React.useMemo(() => { + React.useLayoutEffect(() => { + if (nextInputSelectionRange.current?.length) { + inputRef.current?.setSelectionRange?.( + nextInputSelectionRange.current[0], + nextInputSelectionRange.current[1] + ) + nextInputSelectionRange.current = undefined + } + }) + + const handleSearchCompletion = React.useMemo(() => { return debounce((e: React.ChangeEvent) => { const value = e.target?.value ?? '' const end = e.target?.selectionEnd ?? 0 - const queryname = getSearchCompletionQueryName(value, end) - if (queryname) { - const query = encodeURIComponent(`name:${queryname} kind:function`) + const queryNameMatches = getSearchCompletionQueryName(value, end) + const queryName = queryNameMatches?.[1] + if (queryName) { + const query = encodeURIComponent(`name:${queryName} kind:function`) const url = `/v1beta/search?q=${query}` latestFetchKey.current = url setQueryCompletionUrl(url) @@ -67,39 +88,60 @@ export function PromptForm({ }, 200) }, []) - const onSelectCompletion = ( - inputRef: React.MutableRefObject< - HTMLTextAreaElement | HTMLInputElement | null - >, + const handleCompletionSelect = ( + inputRef: React.RefObject, item: ISearchHit ) => { - const replaceString = '`@' + item?.doc?.name + '` ' const selectionEnd = inputRef.current?.selectionEnd ?? 0 - const queryname = getSearchCompletionQueryName(input, selectionEnd) - const prevInput = input - .substring(0, selectionEnd) - .replace(new RegExp(`@${queryname}$`), '') - if (queryname) { - setInput(prevInput + replaceString + input.substring(selectionEnd)) + const queryNameMatches = getSearchCompletionQueryName(input, selectionEnd) + if (queryNameMatches) { + setSelectedCompletionsMap({ + ...selectedCompletionsMap, + [queryNameMatches[0]]: item + }) + // insert a space to break the search query + setInput(input.slice(0, selectionEnd) + ' ' + input.slice(selectionEnd)) + // store the selection range and update it when layout + nextInputSelectionRange.current = [selectionEnd + 1, selectionEnd + 1] } - setOptions([]) } + const handlePromptSubmit: React.FormEventHandler< + HTMLFormElement + > = async e => { + e.preventDefault() + if (!input?.trim()) { + return + } + + let finalInput = input + // replace queryname to doc.body of selected completions + Object.keys(selectedCompletionsMap).forEach(key => { + const completion = selectedCompletionsMap[key] + if (!completion?.doc) return + finalInput = finalInput.replaceAll( + key, + `${'```'}${completion.doc?.language ?? ''}\n${ + completion.doc.body ?? '' + }\n${'```'}\n` + ) + }) + + setInput('') + await onSubmit(finalInput) + } + return ( -
{ - e.preventDefault() - if (!input?.trim()) { - return - } - setInput('') - await onSubmit(input) - }} - ref={formRef} - > - - {({ open, inputRef, highlightedIndex }) => { + + + {({ open, highlightedIndex }) => { + const highlightedOption = options?.[highlightedIndex] + return ( <> @@ -119,14 +161,11 @@ export function PromptForm({ spellCheck={false} className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm" value={input} - ref={ - inputRef as React.MutableRefObject - } + ref={inputRef} onChange={e => { if (has(e, 'target.value')) { - let event = e as React.ChangeEvent - setInput(event.target.value) - onSearch?.(event) + setInput(e.target.value) + handleSearchCompletion(e) } }} onKeyDown={onKeyDown} @@ -151,11 +190,10 @@ export function PromptForm({ e.preventDefault()} - // popupMatchAnchorWidth - className="w-1/2 max-w-xl" + className="w-[60vw] md:w-[430px]" > - - + +
{open && !!options?.length && @@ -165,9 +203,14 @@ export function PromptForm({ index={index} key={item?.id} > -
-
{item?.doc?.name}
-
+
+
+ +
+ {item?.doc?.name}(...) +
+
+
{item?.doc?.body}
@@ -177,20 +220,23 @@ export function PromptForm({ e.preventDefault()} - onFocus={e => e.preventDefault()} - onClick={e => e.preventDefault()} - className="max-w-xl rounded-none" + onKeyDownCapture={e => e.preventDefault()} + className="rounded-none" + collisionPadding={{ bottom: 120 }} > -
+
- {options?.[highlightedIndex]?.doc?.name} + {highlightedOption?.doc?.kind + ? `(${highlightedOption?.doc?.kind}) ` + : ''} + {highlightedOption?.doc?.name}
-
- {options?.[highlightedIndex]?.doc?.body} +
+ {highlightedOption?.doc?.body}
@@ -203,3 +249,30 @@ export function PromptForm({ ) } + +/** + * Retrieves the name of the completion query from a given string@. + * @param {string} val - The input string to search for the completion query name. + * @param {number | undefined} selectionEnd - The index at which the selection ends in the input string. + * @return {string | undefined} - The name of the completion query if found, otherwise undefined. + */ +export function getSearchCompletionQueryName( + val: string, + selectionEnd: number | undefined +): RegExpExecArray | null { + const queryString = val.substring(0, selectionEnd) + const matches = /@(\w+)$/.exec(queryString) + return matches +} + +function IconForCompletionKind({ + kind, + ...rest +}: { kind: string | undefined } & React.ComponentProps<'svg'>) { + switch (kind) { + case 'function': + return + default: + return + } +} diff --git a/ee/tabby-ui/components/ui/combobox.tsx b/ee/tabby-ui/components/ui/combobox.tsx index d041db4..fda256c 100644 --- a/ee/tabby-ui/components/ui/combobox.tsx +++ b/ee/tabby-ui/components/ui/combobox.tsx @@ -8,16 +8,18 @@ import { } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useCombobox } from 'downshift' -import type { UseComboboxReturnValue } from 'downshift' +import type { + UseComboboxReturnValue, + UseComboboxState, + UseComboboxStateChangeOptions +} from 'downshift' import Textarea from 'react-textarea-autosize' -import { isNil, omitBy } from 'lodash' +import { isNil, omitBy } from 'lodash-es' interface ComboboxContextValue extends UseComboboxReturnValue { open: boolean - inputRef: React.MutableRefObject< - HTMLInputElement | HTMLTextAreaElement | null - > - anchorRef: React.MutableRefObject + inputRef: React.RefObject + anchorRef: React.RefObject } export const ComboboxContext = React.createContext({} as ComboboxContextValue) @@ -133,7 +135,12 @@ export const ComboboxOption = React.forwardRef< disabled && 'pointer-events-none opacity-50', className )} - {...getItemProps({ item, index })} + {...getItemProps({ + item, + index, + onMouseLeave: e => e.preventDefault(), + onMouseOut: e => e.preventDefault() + })} {...rest} > {typeof children === 'function' @@ -148,24 +155,48 @@ ComboboxOption.displayName = 'ComboboxOption' interface ComboboxProps { options: T[] | undefined onSelect?: ( - ref: React.MutableRefObject, + ref: React.RefObject, data: T ) => void + inputRef?: React.RefObject children?: | React.ReactNode | React.ReactNode[] - | ((p: ComboboxContextValue) => React.ReactNode) + | ((contextValue: ComboboxContextValue) => React.ReactNode) } + export function Combobox({ options, onSelect, + inputRef: propsInputRef, children }: ComboboxProps) { const [manualOpen, setManualOpen] = React.useState(false) - const inputRef = React.useRef( + const internalInputRef = React.useRef( null ) - const anchorRef = React.useRef(null) + const inputRef = propsInputRef ?? internalInputRef + const anchorRef = React.useRef(null) + + const stateReducer = React.useCallback( + ( + state: UseComboboxState, + actionAndChanges: UseComboboxStateChangeOptions + ) => { + const { type, changes } = actionAndChanges + switch (type) { + case useCombobox.stateChangeTypes.MenuMouseLeave: + return { + ...changes, + highlightedIndex: state.highlightedIndex + } + default: + return changes + } + }, + [] + ) + const comboboxValue = useCombobox({ items: options ?? [], isOpen: manualOpen, @@ -179,11 +210,12 @@ export function Combobox({ if (!isOpen) { setManualOpen(false) } - } + }, + stateReducer }) - const { setHighlightedIndex, highlightedIndex, isOpen } = comboboxValue - const open = isOpen && !!options?.length + const { setHighlightedIndex, highlightedIndex } = comboboxValue + const open = manualOpen && !!options?.length React.useEffect(() => { if (open && !!options.length && highlightedIndex === -1) { @@ -192,7 +224,7 @@ export function Combobox({ if (open && !options.length) { setManualOpen(false) } - }, [open]) + }, [open, options]) React.useEffect(() => { if (options?.length) { diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx index 657b3ea..eabc2bf 100644 --- a/ee/tabby-ui/components/ui/icons.tsx +++ b/ee/tabby-ui/components/ui/icons.tsx @@ -554,6 +554,24 @@ function IconNotice({ className, ...props }: React.ComponentProps<'svg'>) { ) } +function IconSymbolFunction({ + className, + ...props +}: React.ComponentProps<'svg'>) { + return ( + + + + ) +} + export { IconEdit, IconNextChat, @@ -583,5 +601,6 @@ export { IconExternalLink, IconChevronUpDown, IconSlack, - IconNotice + IconNotice, + IconSymbolFunction } diff --git a/ee/tabby-ui/lib/utils.ts b/ee/tabby-ui/lib/utils.ts index ac318e7..cea3d17 100644 --- a/ee/tabby-ui/lib/utils.ts +++ b/ee/tabby-ui/lib/utils.ts @@ -41,18 +41,3 @@ export function formatDate(input: string | number | Date): string { year: 'numeric' }) } - -/** - * Retrieves the name of the completion query from a given string@. - * @param {string} val - The input string to search for the completion query name. - * @param {number | undefined} selectionEnd - The index at which the selection ends in the input string. - * @return {string | undefined} - The name of the completion query if found, otherwise undefined. - */ -export function getSearchCompletionQueryName( - val: string, - selectionEnd: number | undefined -): string | undefined { - const queryString = val.substring(0, selectionEnd) - const matches = /@(\w+)$/.exec(queryString) - return matches?.[1] -} diff --git a/ee/tabby-ui/package.json b/ee/tabby-ui/package.json index 693921f..9410ddd 100644 --- a/ee/tabby-ui/package.json +++ b/ee/tabby-ui/package.json @@ -33,7 +33,7 @@ "compare-versions": "^6.1.0", "downshift": "^8.2.2", "focus-trap-react": "^10.1.1", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "nanoid": "^4.0.2", "next": "^13.4.7", "next-auth": "0.0.0-manual.83c4ebd1", @@ -52,7 +52,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.9", - "@types/lodash": "^4.14.200", + "@types/lodash-es": "^4.17.10", "@types/node": "^17.0.12", "@types/react": "^18.0.22", "@types/react-dom": "^18.0.7", diff --git a/ee/tabby-ui/yarn.lock b/ee/tabby-ui/yarn.lock index e61c7b4..940998d 100644 --- a/ee/tabby-ui/yarn.lock +++ b/ee/tabby-ui/yarn.lock @@ -734,7 +734,14 @@ resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.16.3.tgz#a341c89705145b7dd8e2a133b282a133eabe6076" integrity sha512-CeVMX9EhVUW8MWnei05eIRks4D5Wscw/W9Byz1s3PA+yJvcdvq9SaDjiUKvRvEgjpdTyJMjQA43ae4KTwsvOPg== -"@types/lodash@^4.14.200": +"@types/lodash-es@^4.17.10": + version "4.17.10" + resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.10.tgz#1b36a76ca9eda20c0263e19bbe1a3adb1b317707" + integrity sha512-YJP+w/2khSBwbUSFdGsSqmDvmnN3cCKoPOL7Zjle6s30ZtemkkqhjVfFqGwPN7ASil5VyjE2GtyU/yqYY6mC0A== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": version "4.14.200" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.200.tgz#435b6035c7eba9cdf1e039af8212c9e9281e7149" integrity sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q== @@ -2636,6 +2643,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.castarray@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115" @@ -2651,11 +2663,6 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - longest-streak@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4"