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:
Naiyuan Qing 2026-02-15 10:32:58 +08:00
parent 339b586025
commit 0c5de3c5f4
3 changed files with 33 additions and 18 deletions

View file

@ -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();

View file

@ -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}
/>

View file

@ -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>
)}