dictation so hot right now

This commit is contained in:
Nate Kelley 2025-09-29 15:37:50 -06:00
parent d5db629b22
commit d46383103d
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 291 additions and 206 deletions

View File

@ -1,12 +1,15 @@
import type { ListShortcutsResponse } from '@buster/server-shared/shortcuts'; import type { ListShortcutsResponse } from '@buster/server-shared/shortcuts';
import type { GetSuggestedPromptsResponse } from '@buster/server-shared/user'; import type { GetSuggestedPromptsResponse } from '@buster/server-shared/user';
import sampleSize from 'lodash/sampleSize'; 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 { 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 { MentionInputSuggestions } from '@/components/ui/inputs/MentionInputSuggestions';
import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { BusterChatInputButtons } from './BusterChatInputButtons'; import { BusterChatInputButtons, type BusterChatInputMode } from './BusterChatInputButtons';
export type BusterChatInput = { export type BusterChatInput = {
defaultValue: string; defaultValue: string;
@ -20,9 +23,12 @@ export type BusterChatInput = {
export const BusterChatInputBase: React.FC<BusterChatInput> = React.memo( export const BusterChatInputBase: React.FC<BusterChatInput> = React.memo(
({ defaultValue, onSubmit, onStop, submitting, disabled, shortcuts, suggestedPrompts }) => { ({ defaultValue, onSubmit, onStop, submitting, disabled, shortcuts, suggestedPrompts }) => {
const mentionInputSuggestionsRef = useRef<MentionInputSuggestionsRef>(null);
const uniqueSuggestions = useUniqueSuggestions(suggestedPrompts); const uniqueSuggestions = useUniqueSuggestions(suggestedPrompts);
const shortcutsSuggestions = useShortcuts(shortcuts); const shortcutsSuggestions = useShortcuts(shortcuts);
const [mode, setMode] = useState<BusterChatInputMode>('auto');
const suggestionItems: MentionInputSuggestionsProps['suggestionItems'] = useMemo(() => { const suggestionItems: MentionInputSuggestionsProps['suggestionItems'] = useMemo(() => {
const items: MentionInputSuggestionsProps['suggestionItems'] = [...uniqueSuggestions]; const items: MentionInputSuggestionsProps['suggestionItems'] = [...uniqueSuggestions];
@ -43,6 +49,10 @@ export const BusterChatInputBase: React.FC<BusterChatInput> = React.memo(
return []; return [];
}, [shortcuts]); }, [shortcuts]);
const onDictate = useMemoizedFn((transcript: string) => {
mentionInputSuggestionsRef.current?.onChangeValue(transcript);
});
const onSubmitPreflight = (value: string) => { const onSubmitPreflight = (value: string) => {
if (submitting) { if (submitting) {
console.warn('Input is submitting'); console.warn('Input is submitting');
@ -65,6 +75,13 @@ export const BusterChatInputBase: React.FC<BusterChatInput> = React.memo(
} }
); );
const onSubmitButton = useMemoizedFn(() => {
const value = mentionInputSuggestionsRef.current?.getValue();
if (value) {
onSubmitPreflight(value.transformedValue);
}
});
return ( return (
<MentionInputSuggestions <MentionInputSuggestions
defaultValue={defaultValue} defaultValue={defaultValue}
@ -72,15 +89,16 @@ export const BusterChatInputBase: React.FC<BusterChatInput> = React.memo(
mentions={mentions} mentions={mentions}
suggestionItems={suggestionItems} suggestionItems={suggestionItems}
placeholder="Ask a question or type / for shortcuts..." placeholder="Ask a question or type / for shortcuts..."
ref={mentionInputSuggestionsRef}
> >
<BusterChatInputButtons <BusterChatInputButtons
onSubmit={() => {}} onSubmit={onSubmitButton}
onStop={() => {}} onStop={onStop}
submitting={submitting} submitting={submitting}
disabled={disabled} disabled={disabled}
mode="auto" mode={mode}
onModeChange={() => {}} onModeChange={setMode}
onDictate={() => {}} onDictate={onDictate}
/> />
</MentionInputSuggestions> </MentionInputSuggestions>
); );

View File

@ -1,5 +1,4 @@
import type React from 'react'; import React, { useEffect } from 'react';
import { useEffect } from 'react';
import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition'; import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition';
import { Button } from '@/components/ui/buttons'; import { Button } from '@/components/ui/buttons';
import { ArrowUp, Magnifier, Sparkle2 } from '@/components/ui/icons'; 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 Microphone from '@/components/ui/icons/NucleoIconOutlined/microphone';
import { Popover } from '@/components/ui/popover'; import { Popover } from '@/components/ui/popover';
import { AppSegmented, type AppSegmentedProps } from '@/components/ui/segmented'; import { AppSegmented, type AppSegmentedProps } from '@/components/ui/segmented';
import { AppTooltip } from '@/components/ui/tooltip';
import { Text } from '@/components/ui/typography'; import { Text } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -20,70 +20,84 @@ type BusterChatInputButtons = {
mode: BusterChatInputMode; mode: BusterChatInputMode;
onModeChange: (mode: BusterChatInputMode) => void; onModeChange: (mode: BusterChatInputMode) => void;
onDictate: (transcript: string) => void; onDictate: (transcript: string) => void;
onDictateListeningChange?: (listening: boolean) => void;
}; };
export const BusterChatInputButtons = ({ export const BusterChatInputButtons = React.memo(
onSubmit, ({
onStop, onSubmit,
submitting, onStop,
disabled, submitting,
mode, disabled,
onModeChange, mode,
onDictate, onModeChange,
}: BusterChatInputButtons) => { onDictate,
const { transcript, listening, resetTranscript, browserSupportsSpeechRecognition } = onDictateListeningChange,
useSpeechRecognition(); }: BusterChatInputButtons) => {
const { transcript, listening, resetTranscript, browserSupportsSpeechRecognition } =
useSpeechRecognition();
const startListening = () => { const startListening = () => {
SpeechRecognition.startListening({ continuous: true }); SpeechRecognition.startListening({ continuous: true });
}; };
const stopListening = () => { const stopListening = () => {
SpeechRecognition.stopListening(); SpeechRecognition.stopListening();
}; };
useEffect(() => { useEffect(() => {
if (listening && transcript) { if (listening && transcript) {
onDictate(transcript); onDictate(transcript);
} }
}, [listening, transcript, onDictate]); }, [listening, transcript, onDictate]);
return ( useEffect(() => {
<div className="flex justify-between items-center gap-2"> onDictateListeningChange?.(listening);
<AppSegmented value={mode} options={modesOptions} onChange={(v) => onModeChange(v.value)} /> }, [listening, onDictateListeningChange]);
<div className="flex items-center gap-2"> return (
{browserSupportsSpeechRecognition && ( <div className="flex justify-between items-center gap-2">
<Button <AppSegmented value={mode} options={modesOptions} onChange={(v) => onModeChange(v.value)} />
rounding={'large'}
variant={'ghost'} <div className="flex items-center gap-2">
prefix={<Microphone />} {browserSupportsSpeechRecognition && (
onClick={listening ? stopListening : startListening} <AppTooltip title={listening ? 'Stop Dictation...' : 'Press to Dictate...'}>
loading={submitting} <Button
disabled={disabled} rounding={'large'}
className={cn( variant={'ghost'}
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform text-text-secondary', prefix={<Microphone />}
!disabled && 'hover:scale-110 active:scale-95', onClick={listening ? stopListening : startListening}
listening && 'bg-item-select text-foreground' loading={submitting}
)} disabled={disabled}
/> className={cn(
)} 'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform text-text-secondary',
<Button !disabled && 'hover:scale-110 active:scale-95',
rounding={'large'} listening && 'bg-item-select text-foreground animate-pulse'
variant={'default'} )}
prefix={<ArrowUp />} />
onClick={submitting ? onStop : onSubmit} </AppTooltip>
loading={submitting}
disabled={disabled}
className={cn(
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform',
!disabled && 'hover:scale-110 active:scale-95'
)} )}
/> <AppTooltip title={'Submit'}>
<Button
rounding={'large'}
variant={'default'}
prefix={<ArrowUp />}
onClick={submitting ? onStop : onSubmit}
loading={submitting}
disabled={disabled}
className={cn(
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform',
!disabled && 'hover:scale-110 active:scale-95'
)}
/>
</AppTooltip>
</div>
</div> </div>
</div> );
); }
}; );
BusterChatInputButtons.displayName = 'BusterChatInputButtons';
const ModePopoverContent = ({ const ModePopoverContent = ({
title, title,

View File

@ -58,6 +58,13 @@ export const MentionInput = forwardRef<MentionInputRef, MentionInputProps>(
); );
}, [mentions]); }, [mentions]);
const getValue = () => {
return onUpdateTransformer({
editor,
mentionsByTrigger,
});
};
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
Document, Document,
@ -94,6 +101,7 @@ export const MentionInput = forwardRef<MentionInputRef, MentionInputProps>(
ref, ref,
() => ({ () => ({
editor, editor,
getValue,
}), }),
[editor] [editor]
); );

View File

@ -1,6 +1,7 @@
import type { MentionNodeAttrs, MentionOptions } from '@tiptap/extension-mention'; import type { MentionNodeAttrs, MentionOptions } from '@tiptap/extension-mention';
import type { Editor, EditorEvents } from '@tiptap/react'; import type { Editor, EditorEvents } from '@tiptap/react';
import type { MentionPillAttributes } from './MentionPill'; import type { MentionPillAttributes } from './MentionPill';
import type { onUpdateTransformer } from './update-transformers';
export type MentionOnSelectParams<T = unknown> = { export type MentionOnSelectParams<T = unknown> = {
value: T; value: T;
@ -106,6 +107,7 @@ export type MentionInputProps = {
export type MentionInputRef = { export type MentionInputRef = {
editor: Editor | null; editor: Editor | null;
getValue: () => ReturnType<typeof onUpdateTransformer>;
}; };
declare module '@tiptap/core' { declare module '@tiptap/core' {

View File

@ -1,11 +1,12 @@
import { Command } from 'cmdk'; 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 { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { MentionInputProps, MentionInputRef } from '../MentionInput'; import type { MentionInputProps, MentionInputRef } from '../MentionInput';
import type { import type {
MentionInputSuggestionsOnSelectParams, MentionInputSuggestionsOnSelectParams,
MentionInputSuggestionsProps, MentionInputSuggestionsProps,
MentionInputSuggestionsRef,
} from './MentionInputSuggestions.types'; } from './MentionInputSuggestions.types';
import { MentionInputSuggestionsContainer } from './MentionInputSuggestionsContainer'; import { MentionInputSuggestionsContainer } from './MentionInputSuggestionsContainer';
import { MentionInputSuggestionsEmpty } from './MentionInputSuggestionsEmpty'; import { MentionInputSuggestionsEmpty } from './MentionInputSuggestionsEmpty';
@ -13,156 +14,191 @@ import { MentionInputSuggestionsItemsSelector } from './MentionInputSuggestionsI
import { MentionInputSuggestionsList } from './MentionInputSuggestionsList'; import { MentionInputSuggestionsList } from './MentionInputSuggestionsList';
import { MentionInputSuggestionsMentionsInput } from './MentionInputSuggestionsMentionsInput'; import { MentionInputSuggestionsMentionsInput } from './MentionInputSuggestionsMentionsInput';
export const MentionInputSuggestions = ({ export const MentionInputSuggestions = forwardRef<
placeholder, MentionInputSuggestionsRef,
defaultValue, MentionInputSuggestionsProps
value: valueProp, >(
emptyComponent, (
submitting, {
onPressEnter, placeholder,
disabled = false, defaultValue,
onChange, value: valueProp,
ariaLabel = 'Mention Input Suggestions', emptyComponent,
readOnly, submitting,
autoFocus, onPressEnter,
children, disabled = false,
//container onChange,
className, ariaLabel = 'Mention Input Suggestions',
inputContainerClassName, readOnly,
suggestionsContainerClassName, autoFocus,
//suggestions children,
suggestionItems, //container
closeSuggestionOnSelect = true, className,
addSuggestionValueToInput = true, inputContainerClassName,
onSuggestionItemClick, suggestionsContainerClassName,
filter, //suggestions
shouldFilter, suggestionItems,
//mentions closeSuggestionOnSelect = true,
onMentionItemClick, addSuggestionValueToInput = true,
mentions, onSuggestionItemClick,
}: MentionInputSuggestionsProps) => { filter,
const [hasClickedSelect, setHasClickedSelect] = useState(false); shouldFilter,
const [value, setValue] = useState(valueProp ?? defaultValue); //mentions
const [hasResults, setHasResults] = useState(!!suggestionItems.length); 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 commandListNavigatedRef = useRef(false);
const commandRef = useRef<HTMLDivElement>(null); const commandRef = useRef<HTMLDivElement>(null);
const mentionsInputRef = useRef<MentionInputRef>(null); const mentionsInputRef = useRef<MentionInputRef>(null);
const showSuggestionList = !hasClickedSelect && suggestionItems.length > 0; const showSuggestionList = !hasClickedSelect && suggestionItems.length > 0;
const onChangeInputValue: MentionInputProps['onChange'] = useCallback( const onChangeInputValue: MentionInputProps['onChange'] = useCallback(
(transformedValue, arrayValue, rawValue) => { (transformedValue, arrayValue, rawValue) => {
setValue(value); setValue(transformedValue);
setHasClickedSelect(false); onChange?.(transformedValue, arrayValue, rawValue);
// Reset command list navigation when user types commandListNavigatedRef.current = false;
commandListNavigatedRef.current = false; setHasClickedSelect(false);
onChange?.(transformedValue, arrayValue, rawValue); },
}, [onChange, setHasClickedSelect]
[] );
);
const onSelectItem = useMemoizedFn( //this is used to change the value of the input from outside the component
({ onClick, ...params }: MentionInputSuggestionsOnSelectParams) => { const onChangeValue = useMemoizedFn((v: string | ((prevState: string) => string)) => {
const { addValueToInput, loading, inputValue, label, disabled } = params; if (typeof v === 'function') {
if (disabled) { setValue((prevState) => {
console.warn('Item is disabled', params); const newState = v(prevState);
return; 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 const onSelectItem = useMemoizedFn(
useEffect(() => { ({ onClick, ...params }: MentionInputSuggestionsOnSelectParams) => {
const handleKeyDown = (event: KeyboardEvent) => { const { addValueToInput, loading, inputValue, label, disabled } = params;
if (showSuggestionList && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { if (disabled) {
commandListNavigatedRef.current = true; console.warn('Item is disabled', params);
} return;
// 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();
} }
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) { const getValue = useMemoizedFn(() => {
commandElement.addEventListener('keydown', handleKeyDown, true); // Use capture phase return mentionsInputRef.current?.getValue();
return () => { });
commandElement.removeEventListener('keydown', handleKeyDown, true);
// 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 commandElement = commandRef.current;
}, [showSuggestionList]); 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 ( return (
<Command <Command
ref={commandRef} ref={commandRef}
value={value} value={value}
label={ariaLabel} label={ariaLabel}
className={cn('relative border rounded overflow-hidden bg-background shadow', className)} className={cn('relative border rounded overflow-hidden bg-background shadow', className)}
shouldFilter={shouldFilter} shouldFilter={shouldFilter}
filter={filter} filter={filter}
>
<MentionInputSuggestionsContainer className={inputContainerClassName}>
<MentionInputSuggestionsMentionsInput
ref={mentionsInputRef}
defaultValue={defaultValue}
readOnly={readOnly}
autoFocus={autoFocus}
placeholder={placeholder}
mentions={mentions}
value={value}
onChange={onChangeInputValue}
onMentionItemClick={onMentionItemClick}
onPressEnter={onPressEnter}
commandListNavigatedRef={commandListNavigatedRef}
disabled={disabled}
/>
{children && <div className="mt-3">{children}</div>}
</MentionInputSuggestionsContainer>
{hasResults && <div className="border-b mb-1.5" />}
<MentionInputSuggestionsList
show={showSuggestionList}
className={cn(suggestionsContainerClassName, hasResults && 'pb-1.5')}
> >
<MentionInputSuggestionsItemsSelector <MentionInputSuggestionsContainer className={inputContainerClassName}>
suggestionItems={suggestionItems} <MentionInputSuggestionsMentionsInput
onSelect={onSelectItem} ref={mentionsInputRef}
addValueToInput={addSuggestionValueToInput} defaultValue={defaultValue}
closeOnSelect={closeSuggestionOnSelect} readOnly={readOnly}
hasResults={hasResults} autoFocus={autoFocus}
setHasResults={setHasResults} placeholder={placeholder}
/> mentions={mentions}
value={value}
onChange={onChangeInputValue}
onMentionItemClick={onMentionItemClick}
onPressEnter={onPressEnter}
commandListNavigatedRef={commandListNavigatedRef}
disabled={disabled}
/>
{children && <div className="mt-3">{children}</div>}
</MentionInputSuggestionsContainer>
{hasResults && <div className="border-b mb-1.5" />}
<MentionInputSuggestionsList
show={showSuggestionList}
className={cn(suggestionsContainerClassName, hasResults && 'pb-1.5')}
>
<MentionInputSuggestionsItemsSelector
suggestionItems={suggestionItems}
onSelect={onSelectItem}
addValueToInput={addSuggestionValueToInput}
closeOnSelect={closeSuggestionOnSelect}
hasResults={hasResults}
setHasResults={setHasResults}
/>
<MentionInputSuggestionsEmpty <MentionInputSuggestionsEmpty
setHasResults={setHasResults} setHasResults={setHasResults}
emptyComponent={emptyComponent} emptyComponent={emptyComponent}
/> />
</MentionInputSuggestionsList> </MentionInputSuggestionsList>
</Command> </Command>
); );
}; }
);

View File

@ -2,6 +2,7 @@ import type { Command } from 'cmdk';
import type React from 'react'; import type React from 'react';
import type { MentionSuggestionExtension } from '../MentionInput'; import type { MentionSuggestionExtension } from '../MentionInput';
import type { MentionInputProps, MentionTriggerItem } from '../MentionInput/MentionInput.types'; 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 * @description Override the addValueToInput and closeOnSelect props for the item based on the group props
@ -81,6 +82,12 @@ export type MentionInputSuggestionsProps<T = string> = {
suggestionsContainerClassName?: string; suggestionsContainerClassName?: string;
} & Pick<React.ComponentProps<typeof Command>, 'filter' | 'shouldFilter'>; } & Pick<React.ComponentProps<typeof Command>, 'filter' | 'shouldFilter'>;
export type MentionInputSuggestionsRef = {
value: string;
onChangeValue: React.Dispatch<React.SetStateAction<string>>;
getValue: () => ReturnType<typeof onUpdateTransformer> | undefined;
};
export type MentionInputSuggestionsContainerProps = { export type MentionInputSuggestionsContainerProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;