diff --git a/packages/ui/src/components/chat-input.tsx b/packages/ui/src/components/chat-input.tsx index b6936593..7098112a 100644 --- a/packages/ui/src/components/chat-input.tsx +++ b/packages/ui/src/components/chat-input.tsx @@ -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( 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(null); const editor = useEditor({ extensions: [ @@ -69,15 +70,11 @@ export const ChatInput = forwardRef( 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( }, }); + // 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( // Expose imperative API useImperativeHandle(ref, () => ({ - getText: () => editor?.state.doc.textContent ?? "", + getText: () => editor?.getText({ blockSeparator: '\n' }) ?? "", setText: (text: string) => { editor?.commands.setContent(text ? `

${text}

` : ""); }, @@ -114,7 +114,8 @@ export const ChatInput = forwardRef( 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(); diff --git a/packages/ui/src/components/markdown/StreamingMarkdown.tsx b/packages/ui/src/components/markdown/StreamingMarkdown.tsx index 282ca65a..19bb142e 100644 --- a/packages/ui/src/components/markdown/StreamingMarkdown.tsx +++ b/packages/ui/src/components/markdown/StreamingMarkdown.tsx @@ -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 ( - + {content} ) }, (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 ( - + {content} ) @@ -222,6 +226,7 @@ export function StreamingMarkdown({ key={key} content={block.content} mode={mode} + className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} /> diff --git a/packages/ui/src/components/message-list.tsx b/packages/ui/src/components/message-list.tsx index 2ff46d5b..b4d80778 100644 --- a/packages/ui/src/components/message-list.tsx +++ b/packages/ui/src/components/message-list.tsx @@ -78,7 +78,7 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }: }, [messages]) return ( -
+
{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 }: )}
{isStreaming ? ( - + ) : ( - + {text} )}