Add in a buster chat input

This commit is contained in:
Nate Kelley 2025-09-29 13:52:00 -06:00
parent 109e2f6398
commit 4215bd2868
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
11 changed files with 169 additions and 40 deletions

View File

@ -122,6 +122,7 @@
"@tiptap/extension-document": "^3.5.0",
"@tiptap/extension-mention": "^3.5.0",
"@tiptap/extension-paragraph": "^3.5.0",
"@tiptap/extension-placeholder": "^3.6.2",
"@tiptap/extension-text": "^3.5.0",
"@tiptap/pm": "^3.5.0",
"@tiptap/react": "^3.5.0",

View File

@ -1,6 +0,0 @@
import React from 'react';
import { MentionInputSuggestions } from '@/components/ui/inputs/MentionInputSuggestions';
export const BusterChatInput = () => {
return <div>BusterChatInput</div>;
};

View File

@ -0,0 +1,127 @@
import type { ListShortcutsResponse } from '@buster/server-shared/shortcuts';
import type { GetSuggestedPromptsResponse } from '@buster/server-shared/user';
import sample from 'lodash/sample';
import sampleSize from 'lodash/sampleSize';
import React, { useMemo } from 'react';
import type { MentionSuggestionExtension } from '@/components/ui/inputs/MentionInput';
import type { MentionInputSuggestionsProps } from '@/components/ui/inputs/MentionInputSuggestions';
import { MentionInputSuggestions } from '@/components/ui/inputs/MentionInputSuggestions';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
export type BusterChatInput = {
defaultValue: string;
onSubmit: (value: string) => void;
onStop: () => void;
submitting: boolean;
disabled: boolean;
shortcuts: ListShortcutsResponse['shortcuts'];
suggestedPrompts: GetSuggestedPromptsResponse['suggestedPrompts'];
};
export const BusterChatInputBase: React.FC<BusterChatInput> = React.memo(
({ defaultValue, onSubmit, onStop, submitting, disabled, shortcuts, suggestedPrompts }) => {
const uniqueSuggestions = useUniqueSuggestions(suggestedPrompts);
const shortcutsSuggestions = useShortcuts(shortcuts);
const suggestionItems: MentionInputSuggestionsProps['suggestionItems'] = useMemo(() => {
const items: MentionInputSuggestionsProps['suggestionItems'] = [...uniqueSuggestions];
if (items.length > 0 && shortcutsSuggestions.length > 0) {
items.push({
type: 'separator',
});
}
if (shortcutsSuggestions.length > 0) {
items.push(...shortcutsSuggestions);
}
return items;
}, [uniqueSuggestions, shortcutsSuggestions]);
const mentions: MentionSuggestionExtension[] = useMemo(() => {
return [];
}, [shortcuts]);
const onSubmitPreflight = (value: string) => {
if (submitting) {
console.warn('Input is submitting');
return;
}
if (disabled) {
console.warn('Input is disabledGlobal');
return;
}
onSubmit(value);
};
const onStopPreflight = useMemoizedFn(() => {
onStop();
});
const onPressEnter: MentionInputSuggestionsProps['onPressEnter'] = useMemoizedFn(
(value, _editorObjects, _rawText) => {
// onSubmitPreflight(value);
}
);
return (
<MentionInputSuggestions
defaultValue={defaultValue}
onPressEnter={onPressEnter}
mentions={mentions}
suggestionItems={suggestionItems}
/>
);
}
);
BusterChatInputBase.displayName = 'BusterChatInputBase';
const useUniqueSuggestions = (
suggestedPrompts: GetSuggestedPromptsResponse['suggestedPrompts']
): MentionInputSuggestionsProps['suggestionItems'] => {
return useMemo(() => {
const allSuggestions: { type: keyof typeof suggestedPrompts; value: string }[] = Object.entries(
suggestedPrompts
).flatMap(([key, value]) => {
return value.map((prompt) => {
return {
type: key as keyof typeof suggestedPrompts,
value: prompt,
};
});
});
// Ensure we have at least 4 suggestions
if (allSuggestions.length < 4) {
throw new Error('Not enough suggestions available - need at least 4');
}
const fourUniqueSuggestions = sampleSize(allSuggestions, 4);
const items: MentionInputSuggestionsProps['suggestionItems'] = fourUniqueSuggestions.map(
(suggestion) => ({
type: 'item',
value: suggestion.type,
label: suggestion.value,
})
);
return items;
}, [suggestedPrompts]);
};
const useShortcuts = (
shortcuts: ListShortcutsResponse['shortcuts']
): MentionInputSuggestionsProps['suggestionItems'] => {
return useMemo(() => {
return shortcuts.map((shortcut) => {
return {
type: 'item',
value: shortcut.name,
label: shortcut.name,
};
});
}, [shortcuts]);
};

View File

@ -296,6 +296,8 @@ const spongebobSuggestions = createMentionSuggestionExtension({
export const Default: Story = {
args: {
className: 'min-w-64',
placeholder: 'Enter text here...',
mentions: [
looneyTunesSuggestions,
theSimpsonsSuggestions,

View File

@ -1,6 +1,7 @@
import { ClientOnly } from '@tanstack/react-router';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Placeholder from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text';
import { EditorContent, EditorContext, useEditor } from '@tiptap/react';
import { cva } from 'class-variance-authority';
@ -39,6 +40,7 @@ export const MentionInput = forwardRef<MentionInputRef, MentionInputProps>(
disabled,
onPressEnter,
commandListNavigatedRef,
placeholder = '',
variant = 'default',
},
ref
@ -61,6 +63,7 @@ export const MentionInput = forwardRef<MentionInputRef, MentionInputProps>(
Document,
Paragraph,
Text,
Placeholder.configure({ placeholder }),
MentionExtension(mentions),
SubmitOnEnter({
mentionsByTrigger,
@ -100,7 +103,11 @@ export const MentionInput = forwardRef<MentionInputRef, MentionInputProps>(
return (
<ClientOnly>
<EditorContext.Provider value={providerValue}>
<EditorContent className="outline-0" editor={editor} style={style} />
<EditorContent
className="outline-0 [&_p.is-editor-empty:first-child:before]:text-gray-light/80 [&_p.is-editor-empty:first-child:before]:content-[attr(data-placeholder)] [&_p.is-editor-empty:first-child:before]:float-left [&_p.is-editor-empty:first-child:before]:h-0 [&_p.is-editor-empty:first-child:before]:pointer-events-none"
editor={editor}
style={style}
/>
</EditorContext.Provider>
</ClientOnly>
);

View File

@ -101,6 +101,7 @@ export type MentionInputProps = {
disabled?: boolean;
commandListNavigatedRef?: React.RefObject<boolean>;
variant?: 'default' | 'ghost';
placeholder?: string;
};
export type MentionInputRef = {

View File

@ -2,7 +2,7 @@ import { Command } from 'cmdk';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { cn } from '@/lib/utils';
import type { MentionInputRef } from '../MentionInput';
import type { MentionInputProps, MentionInputRef } from '../MentionInput';
import type {
MentionInputSuggestionsOnSelectParams,
MentionInputSuggestionsProps,
@ -19,10 +19,8 @@ export const MentionInputSuggestions = ({
value: valueProp,
emptyComponent,
submitting,
onSubmit,
onPressEnter,
disabled: disabledGlobal = false,
onStop,
disabled = false,
onChange,
ariaLabel = 'Mention Input Suggestions',
readOnly,
@ -53,13 +51,16 @@ export const MentionInputSuggestions = ({
const showSuggestionList = !hasClickedSelect && suggestionItems.length > 0;
const onChangeInputValue = useCallback((value: string) => {
setValue(value);
setHasClickedSelect(false);
// Reset command list navigation when user types
commandListNavigatedRef.current = false;
onChange?.(value);
}, []);
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 onSelectItem = useMemoizedFn(
({ onClick, ...params }: MentionInputSuggestionsOnSelectParams) => {
@ -88,22 +89,6 @@ export const MentionInputSuggestions = ({
}
);
const onSubmitPreflight = useMemoizedFn((value: string) => {
if (submitting) {
console.warn('Input is submitting');
return;
}
if (disabledGlobal) {
console.warn('Input is disabledGlobal');
return;
}
onSubmit(value);
});
const onStopPreflight = useMemoizedFn(() => {
onStop();
});
// Track arrow key navigation in the command list
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@ -151,8 +136,9 @@ export const MentionInputSuggestions = ({
value={value}
onChange={onChangeInputValue}
onMentionItemClick={onMentionItemClick}
onPressEnter={onPressEnter || onSubmit}
onPressEnter={onPressEnter}
commandListNavigatedRef={commandListNavigatedRef}
disabled={disabled}
/>
{children}
</MentionInputSuggestionsContainer>

View File

@ -54,13 +54,11 @@ export type MentionInputSuggestionsSeparator = {
export type MentionInputSuggestionsProps<T = string> = {
defaultValue: string;
value?: string;
onChange?: (value: string) => void;
submitting?: boolean;
disabled?: boolean;
readOnly?: boolean;
onSubmit: (value: string) => void;
onChange?: MentionInputProps['onChange'];
onPressEnter: MentionInputProps['onPressEnter'];
onStop: () => void;
autoFocus?: boolean;
placeholder?: string;
ariaLabel?: string;
@ -87,4 +85,4 @@ export type MentionInputSuggestionsContainerProps = {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
} & Pick<MentionInputSuggestionsProps, 'submitting' | 'disabled' | 'onStop' | 'onSubmit'>;
} & Pick<MentionInputSuggestionsProps, 'submitting' | 'disabled' | 'onPressEnter'>;

View File

@ -6,7 +6,7 @@ import type { MentionInputSuggestionsProps } from './MentionInputSuggestions.typ
export type MentionInputSuggestionsMentionsInputProps = Pick<
MentionInputSuggestionsProps,
'mentions' | 'value' | 'placeholder' | 'defaultValue' | 'onMentionItemClick'
'mentions' | 'value' | 'placeholder' | 'defaultValue' | 'onMentionItemClick' | 'disabled'
> & {
onChange: MentionInputProps['onChange'];
onPressEnter: MentionInputProps['onPressEnter'];

View File

@ -1 +1,2 @@
export * from './MentionInputSuggestions';
export * from './MentionInputSuggestions.types';

View File

@ -688,6 +688,9 @@ importers:
'@tiptap/extension-paragraph':
specifier: ^3.5.0
version: 3.5.0(@tiptap/core@3.5.0(@tiptap/pm@3.5.0))
'@tiptap/extension-placeholder':
specifier: ^3.6.2
version: 3.6.2(@tiptap/extensions@3.5.0(@tiptap/core@3.5.0(@tiptap/pm@3.5.0))(@tiptap/pm@3.5.0))
'@tiptap/extension-text':
specifier: ^3.5.0
version: 3.5.0(@tiptap/core@3.5.0(@tiptap/pm@3.5.0))
@ -6118,6 +6121,11 @@ packages:
peerDependencies:
'@tiptap/core': ^3.5.0
'@tiptap/extension-placeholder@3.6.2':
resolution: {integrity: sha512-Fv/iLD0iIa51s5HE202qustunAKDWL4qcRZJlbQJYNqAaVLhC5wjz29o2tgihFMyvgpKLlpYh2xUwCVMc/mGog==}
peerDependencies:
'@tiptap/extensions': ^3.6.2
'@tiptap/extension-strike@3.5.0':
resolution: {integrity: sha512-I4XmXPuCgIQ93Hfn39S8n/EZhiVHSHzU/7awRqQM3cx/kiByc/CiZ86c7opkQozXAIxSycR1IMhS/WVsxQggJw==}
peerDependencies:
@ -18915,6 +18923,10 @@ snapshots:
dependencies:
'@tiptap/core': 3.5.0(@tiptap/pm@3.5.0)
'@tiptap/extension-placeholder@3.6.2(@tiptap/extensions@3.5.0(@tiptap/core@3.5.0(@tiptap/pm@3.5.0))(@tiptap/pm@3.5.0))':
dependencies:
'@tiptap/extensions': 3.5.0(@tiptap/core@3.5.0(@tiptap/pm@3.5.0))(@tiptap/pm@3.5.0)
'@tiptap/extension-strike@3.5.0(@tiptap/core@3.5.0(@tiptap/pm@3.5.0))':
dependencies:
'@tiptap/core': 3.5.0(@tiptap/pm@3.5.0)