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 { 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<BusterChatInput> = React.memo(
({ defaultValue, onSubmit, onStop, submitting, disabled, shortcuts, suggestedPrompts }) => {
const mentionInputSuggestionsRef = useRef<MentionInputSuggestionsRef>(null);
const uniqueSuggestions = useUniqueSuggestions(suggestedPrompts);
const shortcutsSuggestions = useShortcuts(shortcuts);
const [mode, setMode] = useState<BusterChatInputMode>('auto');
const suggestionItems: MentionInputSuggestionsProps['suggestionItems'] = useMemo(() => {
const items: MentionInputSuggestionsProps['suggestionItems'] = [...uniqueSuggestions];
@ -43,6 +49,10 @@ export const BusterChatInputBase: React.FC<BusterChatInput> = 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<BusterChatInput> = React.memo(
}
);
const onSubmitButton = useMemoizedFn(() => {
const value = mentionInputSuggestionsRef.current?.getValue();
if (value) {
onSubmitPreflight(value.transformedValue);
}
});
return (
<MentionInputSuggestions
defaultValue={defaultValue}
@ -72,15 +89,16 @@ export const BusterChatInputBase: React.FC<BusterChatInput> = React.memo(
mentions={mentions}
suggestionItems={suggestionItems}
placeholder="Ask a question or type / for shortcuts..."
ref={mentionInputSuggestionsRef}
>
<BusterChatInputButtons
onSubmit={() => {}}
onStop={() => {}}
onSubmit={onSubmitButton}
onStop={onStop}
submitting={submitting}
disabled={disabled}
mode="auto"
onModeChange={() => {}}
onDictate={() => {}}
mode={mode}
onModeChange={setMode}
onDictate={onDictate}
/>
</MentionInputSuggestions>
);

View File

@ -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 (
<div className="flex justify-between items-center gap-2">
<AppSegmented value={mode} options={modesOptions} onChange={(v) => onModeChange(v.value)} />
useEffect(() => {
onDictateListeningChange?.(listening);
}, [listening, onDictateListeningChange]);
<div className="flex items-center gap-2">
{browserSupportsSpeechRecognition && (
<Button
rounding={'large'}
variant={'ghost'}
prefix={<Microphone />}
onClick={listening ? stopListening : startListening}
loading={submitting}
disabled={disabled}
className={cn(
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform text-text-secondary',
!disabled && 'hover:scale-110 active:scale-95',
listening && 'bg-item-select text-foreground'
)}
/>
)}
<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'
return (
<div className="flex justify-between items-center gap-2">
<AppSegmented value={mode} options={modesOptions} onChange={(v) => onModeChange(v.value)} />
<div className="flex items-center gap-2">
{browserSupportsSpeechRecognition && (
<AppTooltip title={listening ? 'Stop Dictation...' : 'Press to Dictate...'}>
<Button
rounding={'large'}
variant={'ghost'}
prefix={<Microphone />}
onClick={listening ? stopListening : startListening}
loading={submitting}
disabled={disabled}
className={cn(
'origin-center transform-gpu transition-all duration-300 ease-out will-change-transform text-text-secondary',
!disabled && 'hover:scale-110 active:scale-95',
listening && 'bg-item-select text-foreground animate-pulse'
)}
/>
</AppTooltip>
)}
/>
<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>
);
};
);
}
);
BusterChatInputButtons.displayName = 'BusterChatInputButtons';
const ModePopoverContent = ({
title,

View File

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

View File

@ -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<T = unknown> = {
value: T;
@ -106,6 +107,7 @@ export type MentionInputProps = {
export type MentionInputRef = {
editor: Editor | null;
getValue: () => ReturnType<typeof onUpdateTransformer>;
};
declare module '@tiptap/core' {

View File

@ -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<HTMLDivElement>(null);
const mentionsInputRef = useRef<MentionInputRef>(null);
const commandListNavigatedRef = useRef(false);
const commandRef = useRef<HTMLDivElement>(null);
const mentionsInputRef = useRef<MentionInputRef>(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 (
<Command
ref={commandRef}
value={value}
label={ariaLabel}
className={cn('relative border rounded overflow-hidden bg-background shadow', className)}
shouldFilter={shouldFilter}
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')}
return (
<Command
ref={commandRef}
value={value}
label={ariaLabel}
className={cn('relative border rounded overflow-hidden bg-background shadow', className)}
shouldFilter={shouldFilter}
filter={filter}
>
<MentionInputSuggestionsItemsSelector
suggestionItems={suggestionItems}
onSelect={onSelectItem}
addValueToInput={addSuggestionValueToInput}
closeOnSelect={closeSuggestionOnSelect}
hasResults={hasResults}
setHasResults={setHasResults}
/>
<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
suggestionItems={suggestionItems}
onSelect={onSelectItem}
addValueToInput={addSuggestionValueToInput}
closeOnSelect={closeSuggestionOnSelect}
hasResults={hasResults}
setHasResults={setHasResults}
/>
<MentionInputSuggestionsEmpty
setHasResults={setHasResults}
emptyComponent={emptyComponent}
/>
</MentionInputSuggestionsList>
</Command>
);
};
<MentionInputSuggestionsEmpty
setHasResults={setHasResults}
emptyComponent={emptyComponent}
/>
</MentionInputSuggestionsList>
</Command>
);
}
);

View File

@ -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<T = string> = {
suggestionsContainerClassName?: string;
} & 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 = {
children: React.ReactNode;
className?: string;