diff --git a/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx b/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx index cc32fc560..b1baa868a 100644 --- a/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx +++ b/apps/web/src/components/features/input/BusterChatInput/BusterChatInputBase.tsx @@ -1,12 +1,15 @@ import type { ListShortcutsResponse } from '@buster/server-shared/shortcuts'; import type { GetSuggestedPromptsResponse } from '@buster/server-shared/user'; import sampleSize from 'lodash/sampleSize'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import type { MentionSuggestionExtension } from '@/components/ui/inputs/MentionInput'; -import type { MentionInputSuggestionsProps } from '@/components/ui/inputs/MentionInputSuggestions'; +import type { + MentionInputSuggestionsProps, + MentionInputSuggestionsRef, +} from '@/components/ui/inputs/MentionInputSuggestions'; import { MentionInputSuggestions } from '@/components/ui/inputs/MentionInputSuggestions'; import { useMemoizedFn } from '@/hooks/useMemoizedFn'; -import { BusterChatInputButtons } from './BusterChatInputButtons'; +import { BusterChatInputButtons, type BusterChatInputMode } from './BusterChatInputButtons'; export type BusterChatInput = { defaultValue: string; @@ -20,9 +23,12 @@ export type BusterChatInput = { export const BusterChatInputBase: React.FC = React.memo( ({ defaultValue, onSubmit, onStop, submitting, disabled, shortcuts, suggestedPrompts }) => { + const mentionInputSuggestionsRef = useRef(null); const uniqueSuggestions = useUniqueSuggestions(suggestedPrompts); const shortcutsSuggestions = useShortcuts(shortcuts); + const [mode, setMode] = useState('auto'); + const suggestionItems: MentionInputSuggestionsProps['suggestionItems'] = useMemo(() => { const items: MentionInputSuggestionsProps['suggestionItems'] = [...uniqueSuggestions]; @@ -43,6 +49,10 @@ export const BusterChatInputBase: React.FC = React.memo( return []; }, [shortcuts]); + const onDictate = useMemoizedFn((transcript: string) => { + mentionInputSuggestionsRef.current?.onChangeValue(transcript); + }); + const onSubmitPreflight = (value: string) => { if (submitting) { console.warn('Input is submitting'); @@ -65,6 +75,13 @@ export const BusterChatInputBase: React.FC = React.memo( } ); + const onSubmitButton = useMemoizedFn(() => { + const value = mentionInputSuggestionsRef.current?.getValue(); + if (value) { + onSubmitPreflight(value.transformedValue); + } + }); + return ( = React.memo( mentions={mentions} suggestionItems={suggestionItems} placeholder="Ask a question or type ‘/’ for shortcuts..." + ref={mentionInputSuggestionsRef} > {}} - onStop={() => {}} + onSubmit={onSubmitButton} + onStop={onStop} submitting={submitting} disabled={disabled} - mode="auto" - onModeChange={() => {}} - onDictate={() => {}} + mode={mode} + onModeChange={setMode} + onDictate={onDictate} /> ); diff --git a/apps/web/src/components/features/input/BusterChatInput/BusterChatInputButtons.tsx b/apps/web/src/components/features/input/BusterChatInput/BusterChatInputButtons.tsx index a25093fa3..59f77b109 100644 --- a/apps/web/src/components/features/input/BusterChatInput/BusterChatInputButtons.tsx +++ b/apps/web/src/components/features/input/BusterChatInput/BusterChatInputButtons.tsx @@ -1,5 +1,4 @@ -import type React from 'react'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition'; import { Button } from '@/components/ui/buttons'; import { ArrowUp, Magnifier, Sparkle2 } from '@/components/ui/icons'; @@ -7,6 +6,7 @@ import Atom from '@/components/ui/icons/NucleoIconOutlined/atom'; import Microphone from '@/components/ui/icons/NucleoIconOutlined/microphone'; import { Popover } from '@/components/ui/popover'; import { AppSegmented, type AppSegmentedProps } from '@/components/ui/segmented'; +import { AppTooltip } from '@/components/ui/tooltip'; import { Text } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; @@ -20,70 +20,84 @@ type BusterChatInputButtons = { mode: BusterChatInputMode; onModeChange: (mode: BusterChatInputMode) => void; onDictate: (transcript: string) => void; + onDictateListeningChange?: (listening: boolean) => void; }; -export const BusterChatInputButtons = ({ - onSubmit, - onStop, - submitting, - disabled, - mode, - onModeChange, - onDictate, -}: BusterChatInputButtons) => { - const { transcript, listening, resetTranscript, browserSupportsSpeechRecognition } = - useSpeechRecognition(); +export const BusterChatInputButtons = React.memo( + ({ + onSubmit, + onStop, + submitting, + disabled, + mode, + onModeChange, + onDictate, + onDictateListeningChange, + }: BusterChatInputButtons) => { + const { transcript, listening, resetTranscript, browserSupportsSpeechRecognition } = + useSpeechRecognition(); - const startListening = () => { - SpeechRecognition.startListening({ continuous: true }); - }; + const startListening = () => { + SpeechRecognition.startListening({ continuous: true }); + }; - const stopListening = () => { - SpeechRecognition.stopListening(); - }; + const stopListening = () => { + SpeechRecognition.stopListening(); + }; - useEffect(() => { - if (listening && transcript) { - onDictate(transcript); - } - }, [listening, transcript, onDictate]); + useEffect(() => { + if (listening && transcript) { + onDictate(transcript); + } + }, [listening, transcript, onDictate]); - return ( -
- onModeChange(v.value)} /> + useEffect(() => { + onDictateListeningChange?.(listening); + }, [listening, onDictateListeningChange]); -
- {browserSupportsSpeechRecognition && ( -
- - ); -}; + ); + } +); + +BusterChatInputButtons.displayName = 'BusterChatInputButtons'; const ModePopoverContent = ({ title, diff --git a/apps/web/src/components/ui/inputs/MentionInput/MentionInput.tsx b/apps/web/src/components/ui/inputs/MentionInput/MentionInput.tsx index af04d0e29..a330c0f67 100644 --- a/apps/web/src/components/ui/inputs/MentionInput/MentionInput.tsx +++ b/apps/web/src/components/ui/inputs/MentionInput/MentionInput.tsx @@ -58,6 +58,13 @@ export const MentionInput = forwardRef( ); }, [mentions]); + const getValue = () => { + return onUpdateTransformer({ + editor, + mentionsByTrigger, + }); + }; + const editor = useEditor({ extensions: [ Document, @@ -94,6 +101,7 @@ export const MentionInput = forwardRef( ref, () => ({ editor, + getValue, }), [editor] ); diff --git a/apps/web/src/components/ui/inputs/MentionInput/MentionInput.types.ts b/apps/web/src/components/ui/inputs/MentionInput/MentionInput.types.ts index be29c4428..34d55b1dc 100644 --- a/apps/web/src/components/ui/inputs/MentionInput/MentionInput.types.ts +++ b/apps/web/src/components/ui/inputs/MentionInput/MentionInput.types.ts @@ -1,6 +1,7 @@ import type { MentionNodeAttrs, MentionOptions } from '@tiptap/extension-mention'; import type { Editor, EditorEvents } from '@tiptap/react'; import type { MentionPillAttributes } from './MentionPill'; +import type { onUpdateTransformer } from './update-transformers'; export type MentionOnSelectParams = { value: T; @@ -106,6 +107,7 @@ export type MentionInputProps = { export type MentionInputRef = { editor: Editor | null; + getValue: () => ReturnType; }; declare module '@tiptap/core' { diff --git a/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.tsx b/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.tsx index f1ac53117..1c169cdd5 100644 --- a/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.tsx +++ b/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.tsx @@ -1,11 +1,12 @@ import { Command } from 'cmdk'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { cn } from '@/lib/utils'; import type { MentionInputProps, MentionInputRef } from '../MentionInput'; import type { MentionInputSuggestionsOnSelectParams, MentionInputSuggestionsProps, + MentionInputSuggestionsRef, } from './MentionInputSuggestions.types'; import { MentionInputSuggestionsContainer } from './MentionInputSuggestionsContainer'; import { MentionInputSuggestionsEmpty } from './MentionInputSuggestionsEmpty'; @@ -13,156 +14,191 @@ import { MentionInputSuggestionsItemsSelector } from './MentionInputSuggestionsI import { MentionInputSuggestionsList } from './MentionInputSuggestionsList'; import { MentionInputSuggestionsMentionsInput } from './MentionInputSuggestionsMentionsInput'; -export const MentionInputSuggestions = ({ - placeholder, - defaultValue, - value: valueProp, - emptyComponent, - submitting, - onPressEnter, - disabled = false, - onChange, - ariaLabel = 'Mention Input Suggestions', - readOnly, - autoFocus, - children, - //container - className, - inputContainerClassName, - suggestionsContainerClassName, - //suggestions - suggestionItems, - closeSuggestionOnSelect = true, - addSuggestionValueToInput = true, - onSuggestionItemClick, - filter, - shouldFilter, - //mentions - onMentionItemClick, - mentions, -}: MentionInputSuggestionsProps) => { - const [hasClickedSelect, setHasClickedSelect] = useState(false); - const [value, setValue] = useState(valueProp ?? defaultValue); - const [hasResults, setHasResults] = useState(!!suggestionItems.length); +export const MentionInputSuggestions = forwardRef< + MentionInputSuggestionsRef, + MentionInputSuggestionsProps +>( + ( + { + placeholder, + defaultValue, + value: valueProp, + emptyComponent, + submitting, + onPressEnter, + disabled = false, + onChange, + ariaLabel = 'Mention Input Suggestions', + readOnly, + autoFocus, + children, + //container + className, + inputContainerClassName, + suggestionsContainerClassName, + //suggestions + suggestionItems, + closeSuggestionOnSelect = true, + addSuggestionValueToInput = true, + onSuggestionItemClick, + filter, + shouldFilter, + //mentions + onMentionItemClick, + mentions, + }: MentionInputSuggestionsProps, + ref + ) => { + const [hasClickedSelect, setHasClickedSelect] = useState(false); + const [value, setValue] = useState(valueProp ?? defaultValue); + const [hasResults, setHasResults] = useState(!!suggestionItems.length); - const commandListNavigatedRef = useRef(false); - const commandRef = useRef(null); - const mentionsInputRef = useRef(null); + const commandListNavigatedRef = useRef(false); + const commandRef = useRef(null); + const mentionsInputRef = useRef(null); - const showSuggestionList = !hasClickedSelect && suggestionItems.length > 0; + const showSuggestionList = !hasClickedSelect && suggestionItems.length > 0; - const onChangeInputValue: MentionInputProps['onChange'] = useCallback( - (transformedValue, arrayValue, rawValue) => { - setValue(value); - setHasClickedSelect(false); - // Reset command list navigation when user types - commandListNavigatedRef.current = false; - onChange?.(transformedValue, arrayValue, rawValue); - }, - [] - ); + const onChangeInputValue: MentionInputProps['onChange'] = useCallback( + (transformedValue, arrayValue, rawValue) => { + setValue(transformedValue); + onChange?.(transformedValue, arrayValue, rawValue); + commandListNavigatedRef.current = false; + setHasClickedSelect(false); + }, + [onChange, setHasClickedSelect] + ); - const onSelectItem = useMemoizedFn( - ({ onClick, ...params }: MentionInputSuggestionsOnSelectParams) => { - const { addValueToInput, loading, inputValue, label, disabled } = params; - if (disabled) { - console.warn('Item is disabled', params); - return; + //this is used to change the value of the input from outside the component + const onChangeValue = useMemoizedFn((v: string | ((prevState: string) => string)) => { + if (typeof v === 'function') { + setValue((prevState) => { + const newState = v(prevState); + mentionsInputRef.current?.editor?.commands.setContent(newState); + return newState; + }); + } else { + setValue(v); + mentionsInputRef.current?.editor?.commands.setContent(v); } - if (submitting) { - console.warn('Input is submitting'); - return; - } - if (loading) { - console.warn('Item is loading', params); - return; - } - if (addValueToInput) { - const stringValue = inputValue ?? String(label); - mentionsInputRef.current?.editor?.commands.setContent(stringValue); - setValue(stringValue); - } - onClick?.(); - if (closeSuggestionOnSelect) setHasClickedSelect(true); - onSuggestionItemClick?.(params); - setHasResults(false); - } - ); + }); - // Track arrow key navigation in the command list - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (showSuggestionList && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { - commandListNavigatedRef.current = true; - } - - // If Enter is pressed and command list was navigated, manually trigger selection - if (showSuggestionList && event.key === 'Enter' && commandListNavigatedRef.current) { - event.preventDefault(); - event.stopPropagation(); - // Find the currently selected item and trigger its click - const selectedItem = commandElement?.querySelector('[data-selected="true"]') as HTMLElement; - if (selectedItem) { - selectedItem.click(); + const onSelectItem = useMemoizedFn( + ({ onClick, ...params }: MentionInputSuggestionsOnSelectParams) => { + const { addValueToInput, loading, inputValue, label, disabled } = params; + if (disabled) { + console.warn('Item is disabled', params); + return; } + if (submitting) { + console.warn('Input is submitting'); + return; + } + if (loading) { + console.warn('Item is loading', params); + return; + } + if (addValueToInput) { + const stringValue = inputValue ?? String(label); + mentionsInputRef.current?.editor?.commands.setContent(stringValue); + setValue(stringValue); + } + onClick?.(); + if (closeSuggestionOnSelect) setHasClickedSelect(true); + onSuggestionItemClick?.(params); + setHasResults(false); } - }; - const commandElement = commandRef.current; - if (commandElement) { - commandElement.addEventListener('keydown', handleKeyDown, true); // Use capture phase - return () => { - commandElement.removeEventListener('keydown', handleKeyDown, true); + ); + + const getValue = useMemoizedFn(() => { + return mentionsInputRef.current?.getValue(); + }); + + // Track arrow key navigation in the command list + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (showSuggestionList && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { + commandListNavigatedRef.current = true; + } + + // If Enter is pressed and command list was navigated, manually trigger selection + if (showSuggestionList && event.key === 'Enter' && commandListNavigatedRef.current) { + event.preventDefault(); + event.stopPropagation(); + // Find the currently selected item and trigger its click + const selectedItem = commandElement?.querySelector( + '[data-selected="true"]' + ) as HTMLElement; + if (selectedItem) { + selectedItem.click(); + } + } }; - } - }, [showSuggestionList]); + const commandElement = commandRef.current; + if (commandElement) { + commandElement.addEventListener('keydown', handleKeyDown, true); // Use capture phase + return () => { + commandElement.removeEventListener('keydown', handleKeyDown, true); + }; + } + }, [showSuggestionList]); - useImperativeHandle(ref, () => ({}), []); + useImperativeHandle( + ref, + () => ({ + value, + onChangeValue, + getValue, + }), + [value] + ); - return ( - - - - {children &&
{children}
} -
- {hasResults &&
} - - + + + {children &&
{children}
} +
+ {hasResults &&
} + + - - - - ); -}; + + + + ); + } +); diff --git a/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.types.ts b/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.types.ts index 0be7ad998..ab818905e 100644 --- a/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.types.ts +++ b/apps/web/src/components/ui/inputs/MentionInputSuggestions/MentionInputSuggestions.types.ts @@ -2,6 +2,7 @@ import type { Command } from 'cmdk'; import type React from 'react'; import type { MentionSuggestionExtension } from '../MentionInput'; import type { MentionInputProps, MentionTriggerItem } from '../MentionInput/MentionInput.types'; +import type { onUpdateTransformer } from '../MentionInput/update-transformers'; /** * @description Override the addValueToInput and closeOnSelect props for the item based on the group props @@ -81,6 +82,12 @@ export type MentionInputSuggestionsProps = { suggestionsContainerClassName?: string; } & Pick, 'filter' | 'shouldFilter'>; +export type MentionInputSuggestionsRef = { + value: string; + onChangeValue: React.Dispatch>; + getValue: () => ReturnType | undefined; +}; + export type MentionInputSuggestionsContainerProps = { children: React.ReactNode; className?: string;