Skip to content

Commit

Permalink
feat(message-input): add interrupt feature
Browse files Browse the repository at this point in the history
  • Loading branch information
iipanda committed Jan 14, 2025
1 parent 55d6285 commit ee921d6
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 16 deletions.
34 changes: 34 additions & 0 deletions apps/www/content/docs/components/message-input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The MessageInput component provides a rich textarea experience with support for
- Drag and drop file uploads
- Submit on Enter (configurable)
- Stop generation button
- Double-enter interrupt behavior
- Fully customizable styling

## Installation
Expand Down Expand Up @@ -86,6 +87,24 @@ export function BasicMessageInput() {
}
```

### With Interrupt Behavior

```tsx
export function MessageInputWithInterrupt() {
return (
<MessageInput
value={input}
onChange={handleInputChange}
isGenerating={isGenerating}
stop={handleStop}
enableInterrupt={true}
/>
)
}
```

When `enableInterrupt` is enabled and `isGenerating` is true, pressing Enter once will show a prompt asking the user to press Enter again to interrupt the generation. The prompt will disappear either when the user presses Enter again (triggering the stop function) or when the generation completes.

### With File Attachments

```tsx
Expand Down Expand Up @@ -140,6 +159,7 @@ The MessageInput component accepts two sets of props depending on whether file a
| `isGenerating` | `boolean` | Whether AI is generating | Required |
| `placeholder` | `string` | Input placeholder text | "Ask AI..." |
| `allowAttachments` | `boolean` | Enable file attachments |
| `enableInterrupt` | `boolean` | Enable double-enter interrupt | `true` |

### With Attachments

Expand Down Expand Up @@ -205,6 +225,20 @@ Show stop button during generation:
/>
```

### Interrupt Behavior

The double-enter interrupt behavior is enabled by default. To disable it:

```tsx
<MessageInput
value={input}
onChange={handleInputChange}
isGenerating={isGenerating}
stop={handleStop}
enableInterrupt={false}
/>
```

## Theming

The Chat component is using theme colors and fully themable with CSS variables.
2 changes: 1 addition & 1 deletion apps/www/public/r/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"files": [
{
"path": "ui/chat.tsx",
"content": "\"use client\"\n\nimport { forwardRef, useCallback, useState, type ReactElement } from \"react\"\nimport { ArrowDown, ThumbsDown, ThumbsUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useAutoScroll } from \"@/registry/default/hooks/use-auto-scroll\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { type Message } from \"@/registry/default/ui/chat-message\"\nimport { CopyButton } from \"@/registry/default/ui/copy-button\"\nimport { MessageInput } from \"@/registry/default/ui/message-input\"\nimport { MessageList } from \"@/registry/default/ui/message-list\"\nimport { PromptSuggestions } from \"@/registry/default/ui/prompt-suggestions\"\n\ninterface ChatPropsBase {\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n messages: Array<Message>\n input: string\n className?: string\n handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement>\n isGenerating: boolean\n stop?: () => void\n onRateResponse?: (\n messageId: string,\n rating: \"thumbs-up\" | \"thumbs-down\"\n ) => void\n}\n\ninterface ChatPropsWithoutSuggestions extends ChatPropsBase {\n append?: never\n suggestions?: never\n}\n\ninterface ChatPropsWithSuggestions extends ChatPropsBase {\n append: (message: { role: \"user\"; content: string }) => void\n suggestions: string[]\n}\n\ntype ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions\n\nexport function Chat({\n messages,\n handleSubmit,\n input,\n handleInputChange,\n stop,\n isGenerating,\n append,\n suggestions,\n className,\n onRateResponse,\n}: ChatProps) {\n const lastMessage = messages.at(-1)\n const isEmpty = messages.length === 0\n const isTyping = lastMessage?.role === \"user\"\n\n const messageOptions = useCallback(\n (message: Message) => ({\n actions: onRateResponse ? (\n <>\n <div className=\"border-r pr-1\">\n <CopyButton\n content={message.content}\n copyMessage=\"Copied response to clipboard!\"\n />\n </div>\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"h-6 w-6\"\n onClick={() => onRateResponse(message.id, \"thumbs-up\")}\n >\n <ThumbsUp className=\"h-4 w-4\" />\n </Button>\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"h-6 w-6\"\n onClick={() => onRateResponse(message.id, \"thumbs-down\")}\n >\n <ThumbsDown className=\"h-4 w-4\" />\n </Button>\n </>\n ) : (\n <CopyButton\n content={message.content}\n copyMessage=\"Copied response to clipboard!\"\n />\n ),\n }),\n [onRateResponse]\n )\n\n return (\n <ChatContainer className={className}>\n {isEmpty && append && suggestions ? (\n <PromptSuggestions\n label=\"Try these prompts ✨\"\n append={append}\n suggestions={suggestions}\n />\n ) : null}\n\n {messages.length > 0 ? (\n <ChatMessages messages={messages}>\n <MessageList\n messages={messages}\n isTyping={isTyping}\n messageOptions={messageOptions}\n />\n </ChatMessages>\n ) : null}\n\n <ChatForm\n className=\"mt-auto\"\n isPending={isGenerating || isTyping}\n handleSubmit={handleSubmit}\n >\n {({ files, setFiles }) => (\n <MessageInput\n value={input}\n onChange={handleInputChange}\n allowAttachments\n files={files}\n setFiles={setFiles}\n stop={stop}\n isGenerating={isGenerating}\n />\n )}\n </ChatForm>\n </ChatContainer>\n )\n}\nChat.displayName = \"Chat\"\n\nexport function ChatMessages({\n messages,\n children,\n}: React.PropsWithChildren<{\n messages: Message[]\n}>) {\n const {\n containerRef,\n scrollToBottom,\n handleScroll,\n shouldAutoScroll,\n handleTouchStart,\n } = useAutoScroll([messages])\n\n return (\n <div\n className=\"grid overflow-y-auto pb-4 grid-cols-1\"\n ref={containerRef}\n onScroll={handleScroll}\n onTouchStart={handleTouchStart}\n >\n <div className=\"[grid-column:1/1] [grid-row:1/1] max-w-full\">\n {children}\n </div>\n\n <div className=\"flex justify-end items-end [grid-column:1/1] [grid-row:1/1] flex-1\">\n {!shouldAutoScroll && (\n <div className=\"sticky bottom-0 left-0 flex w-full justify-end\">\n <Button\n onClick={scrollToBottom}\n className=\"h-8 w-8 rounded-full ease-in-out animate-in fade-in-0 slide-in-from-bottom-1\"\n size=\"icon\"\n variant=\"ghost\"\n >\n <ArrowDown className=\"h-4 w-4\" />\n </Button>\n </div>\n )}\n </div>\n </div>\n )\n}\n\nexport const ChatContainer = forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n return (\n <div\n ref={ref}\n className={cn(\"grid max-h-full w-full grid-rows-[1fr_auto]\", className)}\n {...props}\n />\n )\n})\nChatContainer.displayName = \"ChatContainer\"\n\ninterface ChatFormProps {\n className?: string\n isPending: boolean\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n children: (props: {\n files: File[] | null\n setFiles: React.Dispatch<React.SetStateAction<File[] | null>>\n }) => ReactElement\n}\n\nexport const ChatForm = forwardRef<HTMLFormElement, ChatFormProps>(\n ({ children, handleSubmit, isPending, className }, ref) => {\n const [files, setFiles] = useState<File[] | null>(null)\n\n const onSubmit = (event: React.FormEvent) => {\n if (isPending) {\n event.preventDefault()\n return\n }\n\n if (!files) {\n handleSubmit(event)\n return\n }\n\n const fileList = createFileList(files)\n handleSubmit(event, { experimental_attachments: fileList })\n setFiles(null)\n }\n\n return (\n <form ref={ref} onSubmit={onSubmit} className={className}>\n {children({ files, setFiles })}\n </form>\n )\n }\n)\nChatForm.displayName = \"ChatForm\"\n\nfunction createFileList(files: File[] | FileList): FileList {\n const dataTransfer = new DataTransfer()\n for (const file of Array.from(files)) {\n dataTransfer.items.add(file)\n }\n return dataTransfer.files\n}\n",
"content": "\"use client\"\n\nimport { forwardRef, useCallback, useState, type ReactElement } from \"react\"\nimport { ArrowDown, ThumbsDown, ThumbsUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useAutoScroll } from \"@/registry/default/hooks/use-auto-scroll\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { type Message } from \"@/registry/default/ui/chat-message\"\nimport { CopyButton } from \"@/registry/default/ui/copy-button\"\nimport { MessageInput } from \"@/registry/default/ui/message-input\"\nimport { MessageList } from \"@/registry/default/ui/message-list\"\nimport { PromptSuggestions } from \"@/registry/default/ui/prompt-suggestions\"\n\ninterface ChatPropsBase {\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n messages: Array<Message>\n input: string\n className?: string\n handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement>\n isGenerating: boolean\n stop?: () => void\n onRateResponse?: (\n messageId: string,\n rating: \"thumbs-up\" | \"thumbs-down\"\n ) => void\n}\n\ninterface ChatPropsWithoutSuggestions extends ChatPropsBase {\n append?: never\n suggestions?: never\n}\n\ninterface ChatPropsWithSuggestions extends ChatPropsBase {\n append: (message: { role: \"user\"; content: string }) => void\n suggestions: string[]\n}\n\ntype ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions\n\nexport function Chat({\n messages,\n handleSubmit,\n input,\n handleInputChange,\n stop,\n isGenerating,\n append,\n suggestions,\n className,\n onRateResponse,\n}: ChatProps) {\n const lastMessage = messages.at(-1)\n const isEmpty = messages.length === 0\n const isTyping = lastMessage?.role === \"user\"\n\n const messageOptions = useCallback(\n (message: Message) => ({\n actions: onRateResponse ? (\n <>\n <div className=\"border-r pr-1\">\n <CopyButton\n content={message.content}\n copyMessage=\"Copied response to clipboard!\"\n />\n </div>\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"h-6 w-6\"\n onClick={() => onRateResponse(message.id, \"thumbs-up\")}\n >\n <ThumbsUp className=\"h-4 w-4\" />\n </Button>\n <Button\n size=\"icon\"\n variant=\"ghost\"\n className=\"h-6 w-6\"\n onClick={() => onRateResponse(message.id, \"thumbs-down\")}\n >\n <ThumbsDown className=\"h-4 w-4\" />\n </Button>\n </>\n ) : (\n <CopyButton\n content={message.content}\n copyMessage=\"Copied response to clipboard!\"\n />\n ),\n }),\n [onRateResponse]\n )\n\n return (\n <ChatContainer className={className}>\n {isEmpty && append && suggestions ? (\n <PromptSuggestions\n label=\"Try these prompts ✨\"\n append={append}\n suggestions={suggestions}\n />\n ) : null}\n\n {messages.length > 0 ? (\n <ChatMessages messages={messages}>\n <MessageList\n messages={messages}\n isTyping={isTyping}\n messageOptions={messageOptions}\n />\n </ChatMessages>\n ) : null}\n\n <ChatForm\n className=\"mt-auto\"\n isPending={isGenerating || isTyping}\n handleSubmit={handleSubmit}\n >\n {({ files, setFiles }) => (\n <MessageInput\n value={input}\n onChange={handleInputChange}\n allowAttachments\n files={files}\n setFiles={setFiles}\n stop={stop}\n isGenerating={isGenerating}\n />\n )}\n </ChatForm>\n </ChatContainer>\n )\n}\nChat.displayName = \"Chat\"\n\nexport function ChatMessages({\n messages,\n children,\n}: React.PropsWithChildren<{\n messages: Message[]\n}>) {\n const {\n containerRef,\n scrollToBottom,\n handleScroll,\n shouldAutoScroll,\n handleTouchStart,\n } = useAutoScroll([messages])\n\n return (\n <div\n className=\"grid grid-cols-1 overflow-y-auto pb-4\"\n ref={containerRef}\n onScroll={handleScroll}\n onTouchStart={handleTouchStart}\n >\n <div className=\"max-w-full [grid-column:1/1] [grid-row:1/1]\">\n {children}\n </div>\n\n <div className=\"flex flex-1 items-end justify-end [grid-column:1/1] [grid-row:1/1]\">\n {!shouldAutoScroll && (\n <div className=\"sticky bottom-0 left-0 flex w-full justify-end\">\n <Button\n onClick={scrollToBottom}\n className=\"h-8 w-8 rounded-full ease-in-out animate-in fade-in-0 slide-in-from-bottom-1\"\n size=\"icon\"\n variant=\"ghost\"\n >\n <ArrowDown className=\"h-4 w-4\" />\n </Button>\n </div>\n )}\n </div>\n </div>\n )\n}\n\nexport const ChatContainer = forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n return (\n <div\n ref={ref}\n className={cn(\"grid max-h-full w-full grid-rows-[1fr_auto]\", className)}\n {...props}\n />\n )\n})\nChatContainer.displayName = \"ChatContainer\"\n\ninterface ChatFormProps {\n className?: string\n isPending: boolean\n handleSubmit: (\n event?: { preventDefault?: () => void },\n options?: { experimental_attachments?: FileList }\n ) => void\n children: (props: {\n files: File[] | null\n setFiles: React.Dispatch<React.SetStateAction<File[] | null>>\n }) => ReactElement\n}\n\nexport const ChatForm = forwardRef<HTMLFormElement, ChatFormProps>(\n ({ children, handleSubmit, isPending, className }, ref) => {\n const [files, setFiles] = useState<File[] | null>(null)\n\n const onSubmit = (event: React.FormEvent) => {\n if (!files) {\n handleSubmit(event)\n return\n }\n\n const fileList = createFileList(files)\n handleSubmit(event, { experimental_attachments: fileList })\n setFiles(null)\n }\n\n return (\n <form ref={ref} onSubmit={onSubmit} className={className}>\n {children({ files, setFiles })}\n </form>\n )\n }\n)\nChatForm.displayName = \"ChatForm\"\n\nfunction createFileList(files: File[] | FileList): FileList {\n const dataTransfer = new DataTransfer()\n for (const file of Array.from(files)) {\n dataTransfer.items.add(file)\n }\n return dataTransfer.files\n}\n",
"type": "registry:ui",
"target": ""
}
Expand Down
Loading

0 comments on commit ee921d6

Please sign in to comment.