fix(ui): preserve newlines in chat input multiline text
- Use TipTap's getText({ blockSeparator: '\n' }) instead of doc.textContent
to preserve newlines between paragraphs when submitting messages
- Add whitespace-pre-wrap CSS to user message bubbles to render newlines
- Add className prop support to StreamingMarkdown component
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
339b586025
commit
0c5de3c5f4
3 changed files with 33 additions and 18 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
import { useRef, useEffect, useImperativeHandle, forwardRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
|
@ -27,9 +27,10 @@ interface ChatInputProps {
|
|||
|
||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
function ChatInput({ onSubmit, onAbort, isLoading, disabled, placeholder = "Type a message...", defaultValue }, ref) {
|
||||
// Use ref to avoid stale closure in Tiptap keydown handler
|
||||
// Use refs to avoid stale closures in Tiptap keydown handler
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
|
|
@ -69,15 +70,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const text = _view.state.doc.textContent;
|
||||
// Use TipTap's getText API to preserve newlines between paragraphs
|
||||
const text = editorRef.current?.getText({ blockSeparator: '\n' }) ?? '';
|
||||
if (!text.trim()) return true;
|
||||
onSubmitRef.current?.(text);
|
||||
// Clear editor after submit
|
||||
_view.dispatch(
|
||||
_view.state.tr
|
||||
.delete(0, _view.state.doc.content.size)
|
||||
.setMeta("addToHistory", false),
|
||||
);
|
||||
editorRef.current?.commands.clearContent();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +83,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||
},
|
||||
});
|
||||
|
||||
// Keep editorRef in sync for use in handleKeyDown closure
|
||||
editorRef.current = editor;
|
||||
|
||||
// Sync disabled state
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
|
@ -104,7 +104,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||
|
||||
// Expose imperative API
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => editor?.state.doc.textContent ?? "",
|
||||
getText: () => editor?.getText({ blockSeparator: '\n' }) ?? "",
|
||||
setText: (text: string) => {
|
||||
editor?.commands.setContent(text ? `<p>${text}</p>` : "");
|
||||
},
|
||||
|
|
@ -114,7 +114,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||
|
||||
const handleSubmit = () => {
|
||||
if (!editor) return;
|
||||
const text = editor.state.doc.textContent;
|
||||
// Use TipTap's getText API to preserve newlines between paragraphs
|
||||
const text = editor.getText({ blockSeparator: '\n' });
|
||||
if (!text.trim()) return;
|
||||
onSubmit?.(text);
|
||||
editor.commands.clearContent();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface StreamingMarkdownProps {
|
|||
content: string
|
||||
isStreaming: boolean
|
||||
mode?: RenderMode
|
||||
className?: string
|
||||
onUrlClick?: (url: string) => void
|
||||
onFileClick?: (path: string) => void
|
||||
}
|
||||
|
|
@ -132,23 +133,25 @@ const MemoizedBlock = React.memo(
|
|||
function Block({
|
||||
content,
|
||||
mode,
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick
|
||||
}: {
|
||||
content: string
|
||||
mode: RenderMode
|
||||
className?: string
|
||||
onUrlClick?: (url: string) => void
|
||||
onFileClick?: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Markdown mode={mode} onUrlClick={onUrlClick} onFileClick={onFileClick}>
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
// Only re-render if content actually changed
|
||||
return prev.content === next.content && prev.mode === next.mode
|
||||
return prev.content === next.content && prev.mode === next.mode && prev.className === next.className
|
||||
}
|
||||
)
|
||||
MemoizedBlock.displayName = 'MemoizedBlock'
|
||||
|
|
@ -173,6 +176,7 @@ export function StreamingMarkdown({
|
|||
content,
|
||||
isStreaming,
|
||||
mode = 'minimal',
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick
|
||||
}: StreamingMarkdownProps): React.JSX.Element {
|
||||
|
|
@ -186,7 +190,7 @@ export function StreamingMarkdown({
|
|||
// Not streaming - use simple Markdown (no block splitting needed)
|
||||
if (!isStreaming) {
|
||||
return (
|
||||
<Markdown mode={mode} onUrlClick={onUrlClick} onFileClick={onFileClick}>
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
|
|
@ -222,6 +226,7 @@ export function StreamingMarkdown({
|
|||
key={key}
|
||||
content={block.content}
|
||||
mode={mode}
|
||||
className={className}
|
||||
onUrlClick={onUrlClick}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
|
|||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="relative p-6 px-4 sm:px-10 max-w-4xl mx-auto">
|
||||
<div className="container relative p-6 px-4 sm:px-10">
|
||||
{messages.map((msg) => {
|
||||
// System messages (e.g. compaction notifications)
|
||||
if (msg.role === "system") {
|
||||
|
|
@ -125,13 +125,22 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
|
|||
)}
|
||||
<div
|
||||
className={cn(
|
||||
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] py-1 px-2.5 my-2" : "w-full py-1 px-2.5 my-1"
|
||||
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-3 px-4" : "w-full p-3 my-1"
|
||||
)}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<StreamingMarkdown content={text} isStreaming={true} mode="minimal" />
|
||||
<StreamingMarkdown
|
||||
content={text}
|
||||
isStreaming={true}
|
||||
mode="minimal"
|
||||
className={msg.role === "user" ? "[&_p]:whitespace-pre-wrap" : ""}
|
||||
/>
|
||||
) : (
|
||||
<MemoizedMarkdown mode="minimal" id={msg.id}>
|
||||
<MemoizedMarkdown
|
||||
mode="minimal"
|
||||
id={msg.id}
|
||||
className={msg.role === "user" ? "[&_p]:whitespace-pre-wrap" : ""}
|
||||
>
|
||||
{text}
|
||||
</MemoizedMarkdown>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue