'use client'; import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; import { insertMention, slateContentToString } from "@/features/chat/utils"; import { cn, IS_MAC } from "@/lib/utils"; import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; import { ArrowUp, Loader2, StopCircleIcon, TriangleAlertIcon } from "lucide-react"; import { Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Descendant, insertText } from "slate"; import { Editable, ReactEditor, RenderElementProps, RenderLeafProps, useFocused, useSelected, useSlate } from "slate-react"; import { useSelectedLanguageModel } from "../../useSelectedLanguageModel"; import { SuggestionBox } from "./suggestionsBox"; import { Suggestion } from "./types"; import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery"; import { useSuggestionsData } from "./useSuggestionsData"; import { useToast } from "@/components/hooks/use-toast"; import { SearchContextQuery } from "@/lib/types"; interface ChatBoxProps { onSubmit: (children: Descendant[], editor: CustomEditor) => void; onStop?: () => void; preferredSuggestionsBoxPlacement?: "top-start" | "bottom-start"; className?: string; isRedirecting?: boolean; isGenerating?: boolean; isDisabled?: boolean; languageModels: LanguageModelInfo[]; selectedSearchScopes: SearchScope[]; searchContexts: SearchContextQuery[]; onContextSelectorOpenChanged: (isOpen: boolean) => void; } export const ChatBox = ({ onSubmit: _onSubmit, onStop, preferredSuggestionsBoxPlacement = "bottom-start", className, isRedirecting, isGenerating, isDisabled, languageModels, selectedSearchScopes, searchContexts, onContextSelectorOpenChanged, }: ChatBoxProps) => { const suggestionsBoxRef = useRef(null); const [index, setIndex] = useState(0); const editor = useSlate(); const { suggestionQuery, suggestionMode, range } = useSuggestionModeAndQuery(); const { suggestions, isLoading } = useSuggestionsData({ suggestionMode, suggestionQuery, selectedRepos: selectedSearchScopes.map((item) => { if (item.type === 'repo') { return [item.value]; } if (item.type === 'reposet') { const reposet = searchContexts.find((reposet) => reposet.name === item.value); if (reposet) { return reposet.repoNames; } } return []; }).flat(), }); const { selectedLanguageModel } = useSelectedLanguageModel({ languageModels, }); const { toast } = useToast(); // Reset the index when the suggestion mode changes. useEffect(() => { setIndex(0); }, [suggestionMode]); // Hotkey to focus the chat box. useHotkeys("/", (e) => { e.preventDefault(); ReactEditor.focus(editor); }); // Auto-focus chat box when the component mounts. useEffect(() => { ReactEditor.focus(editor); }, [editor]); const renderElement = useCallback((props: RenderElementProps) => { switch (props.element.type) { case 'mention': return } /> default: return } }, []); const renderLeaf = useCallback((props: RenderLeafProps) => { return }, []); const { isSubmitDisabled, isSubmitDisabledReason } = useMemo((): { isSubmitDisabled: true, isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-repos-selected" | "no-language-model-selected" } | { isSubmitDisabled: false, isSubmitDisabledReason: undefined, } => { if (slateContentToString(editor.children).trim().length === 0) { return { isSubmitDisabled: true, isSubmitDisabledReason: "empty", } } if (isRedirecting) { return { isSubmitDisabled: true, isSubmitDisabledReason: "redirecting", } } if (isGenerating) { return { isSubmitDisabled: true, isSubmitDisabledReason: "generating", } } if (selectedSearchScopes.length === 0) { return { isSubmitDisabled: true, isSubmitDisabledReason: "no-repos-selected", } } if (selectedLanguageModel === undefined) { return { isSubmitDisabled: true, isSubmitDisabledReason: "no-language-model-selected", } } return { isSubmitDisabled: false, isSubmitDisabledReason: undefined, } }, [ editor.children, isRedirecting, isGenerating, selectedSearchScopes.length, selectedLanguageModel, ]) const onSubmit = useCallback(() => { if (isSubmitDisabled) { if (isSubmitDisabledReason === "no-repos-selected") { toast({ description: "⚠️ You must select at least one search scope", variant: "destructive", }); onContextSelectorOpenChanged(true); } if (isSubmitDisabledReason === "no-language-model-selected") { toast({ description: "⚠️ You must select a language model", variant: "destructive", }); } return; } _onSubmit(editor.children, editor); }, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast, onContextSelectorOpenChanged]); const onInsertSuggestion = useCallback((suggestion: Suggestion) => { switch (suggestion.type) { case 'file': insertMention(editor, { type: 'file', path: suggestion.path, repo: suggestion.repo, name: suggestion.name, language: suggestion.language, revision: suggestion.revision, }, range); break; case 'refine': { switch (suggestion.targetSuggestionMode) { case 'file': insertText(editor, 'file:'); break; } break; } } ReactEditor.focus(editor); }, [editor, range]); const onKeyDown = useCallback((event: KeyboardEvent) => { if (suggestionMode === "none") { switch (event.key) { case 'Enter': { if (event.shiftKey) { break; } event.preventDefault(); onSubmit(); break; } } } else if (suggestions.length > 0) { switch (event.key) { case 'ArrowDown': { event.preventDefault(); const prevIndex = index >= suggestions.length - 1 ? 0 : index + 1 setIndex(prevIndex) break; } case 'ArrowUp': { event.preventDefault(); const nextIndex = index <= 0 ? suggestions.length - 1 : index - 1 setIndex(nextIndex) break; } case 'Tab': case 'Enter': { event.preventDefault(); const suggestion = suggestions[index]; onInsertSuggestion(suggestion); break; } case 'Escape': { event.preventDefault(); break; } } } }, [suggestionMode, suggestions, onSubmit, index, onInsertSuggestion]); useEffect(() => { if (!range || !suggestionsBoxRef.current) { return; } const virtualElement: VirtualElement = { getBoundingClientRect: () => { if (!range) { return new DOMRect(); } return ReactEditor.toDOMRange(editor, range).getBoundingClientRect(); } } computePosition(virtualElement, suggestionsBoxRef.current, { placement: preferredSuggestionsBoxPlacement, middleware: [ offset(2), flip({ mainAxis: true, crossAxis: false, fallbackPlacements: ['top-start', 'bottom-start'], padding: 20, }), shift({ padding: 5, }) ] }).then(({ x, y }) => { if (suggestionsBoxRef.current) { suggestionsBoxRef.current.style.left = `${x}px`; suggestionsBoxRef.current.style.top = `${y}px`; } }) }, [editor, index, range, preferredSuggestionsBoxPlacement]); return (
{isRedirecting ? ( ) : isGenerating ? ( ) : (
{ // @hack: When submission is disabled, we still want to issue // a warning to the user as to why the submission is disabled. // onSubmit on the Button will not be called because of the // disabled prop, hence the call here. if (isSubmitDisabled) { onSubmit(); } }} >
{(isSubmitDisabled && isSubmitDisabledReason === "no-repos-selected") && (
You must select at least one search scope
)}
)}
{suggestionMode !== "none" && ( )}
) } const DefaultElement = (props: RenderElementProps) => { return

{props.children}

} const Leaf = (props: RenderLeafProps) => { return ( {props.children} ) } const MentionComponent = ({ attributes, children, element: { data }, }: RenderElementPropsFor) => { const selected = useSelected(); const focused = useFocused(); if (data.type === 'file') { return ( {/* @see: https://github.com/ianstormtaylor/slate/issues/3490 */} {IS_MAC ? ( {children} {data.name} ) : ( {data.name} {children} )} {data.repo.split('/').pop()}/{data.path} ) } }