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,9 +20,11 @@ type BusterChatInputButtons = {
mode: BusterChatInputMode;
onModeChange: (mode: BusterChatInputMode) => void;
onDictate: (transcript: string) => void;
onDictateListeningChange?: (listening: boolean) => void;
};
export const BusterChatInputButtons = ({
export const BusterChatInputButtons = React.memo(
({
onSubmit,
onStop,
submitting,
@ -30,6 +32,7 @@ export const BusterChatInputButtons = ({
mode,
onModeChange,
onDictate,
onDictateListeningChange,
}: BusterChatInputButtons) => {
const { transcript, listening, resetTranscript, browserSupportsSpeechRecognition } =
useSpeechRecognition();
@ -48,12 +51,17 @@ export const BusterChatInputButtons = ({
}
}, [listening, transcript, onDictate]);
useEffect(() => {
onDictateListeningChange?.(listening);
}, [listening, onDictateListeningChange]);
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'}
@ -64,10 +72,12 @@ export const BusterChatInputButtons = ({
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'
listening && 'bg-item-select text-foreground animate-pulse'
)}
/>
</AppTooltip>
)}
<AppTooltip title={'Submit'}>
<Button
rounding={'large'}
variant={'default'}
@ -80,10 +90,14 @@ export const BusterChatInputButtons = ({
!disabled && 'hover:scale-110 active:scale-95'
)}
/>
</AppTooltip>
</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,7 +14,12 @@ import { MentionInputSuggestionsItemsSelector } from './MentionInputSuggestionsI
import { MentionInputSuggestionsList } from './MentionInputSuggestionsList';
import { MentionInputSuggestionsMentionsInput } from './MentionInputSuggestionsMentionsInput';
export const MentionInputSuggestions = ({
export const MentionInputSuggestions = forwardRef<
MentionInputSuggestionsRef,
MentionInputSuggestionsProps
>(
(
{
placeholder,
defaultValue,
value: valueProp,
@ -40,7 +46,9 @@ export const MentionInputSuggestions = ({
//mentions
onMentionItemClick,
mentions,
}: MentionInputSuggestionsProps) => {
}: MentionInputSuggestionsProps,
ref
) => {
const [hasClickedSelect, setHasClickedSelect] = useState(false);
const [value, setValue] = useState(valueProp ?? defaultValue);
const [hasResults, setHasResults] = useState(!!suggestionItems.length);
@ -53,15 +61,28 @@ export const MentionInputSuggestions = ({
const onChangeInputValue: MentionInputProps['onChange'] = useCallback(
(transformedValue, arrayValue, rawValue) => {
setValue(value);
setHasClickedSelect(false);
// Reset command list navigation when user types
commandListNavigatedRef.current = false;
setValue(transformedValue);
onChange?.(transformedValue, arrayValue, rawValue);
commandListNavigatedRef.current = false;
setHasClickedSelect(false);
},
[]
[onChange, setHasClickedSelect]
);
//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);
}
});
const onSelectItem = useMemoizedFn(
({ onClick, ...params }: MentionInputSuggestionsOnSelectParams) => {
const { addValueToInput, loading, inputValue, label, disabled } = params;
@ -89,6 +110,10 @@ export const MentionInputSuggestions = ({
}
);
const getValue = useMemoizedFn(() => {
return mentionsInputRef.current?.getValue();
});
// Track arrow key navigation in the command list
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@ -101,7 +126,9 @@ export const MentionInputSuggestions = ({
event.preventDefault();
event.stopPropagation();
// Find the currently selected item and trigger its click
const selectedItem = commandElement?.querySelector('[data-selected="true"]') as HTMLElement;
const selectedItem = commandElement?.querySelector(
'[data-selected="true"]'
) as HTMLElement;
if (selectedItem) {
selectedItem.click();
}
@ -116,7 +143,15 @@ export const MentionInputSuggestions = ({
}
}, [showSuggestionList]);
useImperativeHandle(ref, () => ({}), []);
useImperativeHandle(
ref,
() => ({
value,
onChangeValue,
getValue,
}),
[value]
);
return (
<Command
@ -165,4 +200,5 @@ export const MentionInputSuggestions = ({
</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;