From 0c5de3c5f42f0ecadeca758f9e79b95692aa88e6 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:32:58 +0800 Subject: [PATCH 1/4] 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 --- packages/ui/src/components/chat-input.tsx | 23 ++++++++++--------- .../components/markdown/StreamingMarkdown.tsx | 11 ++++++--- packages/ui/src/components/message-list.tsx | 17 ++++++++++---- 3 files changed, 33 insertions(+), 18 deletions(-) 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} )} From c6ca5f327089502ed4a2c124436362d078f74e3f Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:47:59 +0800 Subject: [PATCH 2/4] refactor(ui): unify container layout and adjust spacing - Use container utility class consistently across chat components - Change container max-width from 5xl to 4xl for better readability - Adjust message bubble padding (p-3 -> p-2) - Fix logout dropdown alignment and add destructive variant Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/renderer/src/pages/layout.tsx | 4 ++-- packages/ui/src/components/chat-skeleton.tsx | 2 +- packages/ui/src/components/chat-view.tsx | 6 +++--- packages/ui/src/components/message-list.tsx | 4 ++-- packages/ui/src/styles/globals.css | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index 476f61c7..9cef56e4 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -284,8 +284,8 @@ export default function Layout() {
- - + + Log out diff --git a/packages/ui/src/components/chat-skeleton.tsx b/packages/ui/src/components/chat-skeleton.tsx index 405d2723..ea9f289c 100644 --- a/packages/ui/src/components/chat-skeleton.tsx +++ b/packages/ui/src/components/chat-skeleton.tsx @@ -5,7 +5,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton"; /** Skeleton placeholder matching MessageList layout, shown while reconnecting */ export function ChatSkeleton() { return ( -
+
{/* Assistant message */}
diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 5a1c92df..9219f781 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -145,7 +145,7 @@ export function ChatView({
{isLoadingHistory && messages.length === 0 ? ( -
+
{/* User bubble */}
@@ -211,7 +211,7 @@ export function ChatView({ )} {isLoading && streamingIds.size === 0 && pendingApprovals.length === 0 && ( -
+
Generating... @@ -219,7 +219,7 @@ export function ChatView({
)} {pendingApprovals.length > 0 && ( -
+
{pendingApprovals.map((approval) => ( +
{messages.map((msg) => { // System messages (e.g. compaction notifications) if (msg.role === "system") { @@ -125,7 +125,7 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }: )}
{isStreaming ? ( diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 7c7f1cf1..b66018d1 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -257,7 +257,7 @@ } @utility container { - @apply w-full max-w-5xl mx-auto; + @apply w-full max-w-4xl mx-auto; } @layer base { From deb747a8593701e3516125c0027e410485b07763 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:52:46 +0800 Subject: [PATCH 3/4] refactor(ui): unify loading indicator component - Create LoadingIndicator component with "generating" and "streaming" variants - Remove inline loading indicator from StreamingMarkdown (empty content returns empty fragment) - Use unified LoadingIndicator in ChatView with consistent positioning - Eliminates layout shift between different loading states Co-Authored-By: Claude Opus 4.5 --- packages/ui/src/components/chat-view.tsx | 13 ++++---- .../ui/src/components/loading-indicator.tsx | 30 +++++++++++++++++++ .../components/markdown/StreamingMarkdown.tsx | 17 ++--------- 3 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/components/loading-indicator.tsx diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 9219f781..92c2cb79 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -8,7 +8,7 @@ import { MessageList } from "@multica/ui/components/message-list"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { MulticaIcon } from "@multica/ui/components/multica-icon"; import { ExecApprovalItem } from "@multica/ui/components/exec-approval-item"; -import { Spinner } from "@multica/ui/components/spinner"; +import { LoadingIndicator } from "@multica/ui/components/loading-indicator"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; import type { Message } from "@multica/store"; @@ -210,12 +210,11 @@ export function ChatView({
)} - {isLoading && streamingIds.size === 0 && pendingApprovals.length === 0 && ( -
-
- - Generating... -
+ {isLoading && pendingApprovals.length === 0 && ( +
+ 0 ? "streaming" : "generating"} + />
)} {pendingApprovals.length > 0 && ( diff --git a/packages/ui/src/components/loading-indicator.tsx b/packages/ui/src/components/loading-indicator.tsx new file mode 100644 index 00000000..3db5027e --- /dev/null +++ b/packages/ui/src/components/loading-indicator.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Spinner } from "@multica/ui/components/spinner"; +import { cn } from "@multica/ui/lib/utils"; + +export type LoadingVariant = "generating" | "streaming"; + +interface LoadingIndicatorProps { + variant: LoadingVariant; + className?: string; +} + +const VARIANT_TEXT: Record = { + generating: "Generating...", + streaming: "Streaming...", +}; + +/** + * Unified loading indicator for chat. + * Use "generating" when waiting for AI response (no content yet). + * Use "streaming" when content is actively being received. + */ +export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) { + return ( +
+ + {VARIANT_TEXT[variant]} +
+ ); +} diff --git a/packages/ui/src/components/markdown/StreamingMarkdown.tsx b/packages/ui/src/components/markdown/StreamingMarkdown.tsx index 19bb142e..aad2f330 100644 --- a/packages/ui/src/components/markdown/StreamingMarkdown.tsx +++ b/packages/ui/src/components/markdown/StreamingMarkdown.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { Markdown, type RenderMode } from './Markdown' -import { Spinner } from '@multica/ui/components/spinner' export interface StreamingMarkdownProps { content: string @@ -196,22 +195,11 @@ export function StreamingMarkdown({ ) } + // Empty content - return null, let parent handle loading indicator if (blocks.length === 0) { - return ( -
- - Generating... -
- ) + return <> } - const indicator = ( -
- - Generating... -
- ) - return ( <> {blocks.map((block, i) => { @@ -232,7 +220,6 @@ export function StreamingMarkdown({ /> ) })} - {indicator} ) } From 430f2c177ed2067525b3f67bae08c6004ec81269 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:03:59 +0800 Subject: [PATCH 4/4] refactor(ui): move LoadingIndicator into MessageList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move LoadingIndicator from ChatView into MessageList for consistent padding - Add isLoading and hasPendingApprovals props to MessageList - Adjust message spacing (my-1 → my-2) for better visual balance Co-Authored-By: Claude Opus 4.5 --- packages/ui/src/components/chat-view.tsx | 15 ++++++--------- packages/ui/src/components/message-list.tsx | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 92c2cb79..7bd6cffb 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -8,7 +8,6 @@ import { MessageList } from "@multica/ui/components/message-list"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { MulticaIcon } from "@multica/ui/components/multica-icon"; import { ExecApprovalItem } from "@multica/ui/components/exec-approval-item"; -import { LoadingIndicator } from "@multica/ui/components/loading-indicator"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; import type { Message } from "@multica/store"; @@ -209,14 +208,12 @@ export function ChatView({
)} - - {isLoading && pendingApprovals.length === 0 && ( -
- 0 ? "streaming" : "generating"} - /> -
- )} + 0} + /> {pendingApprovals.length > 0 && (
{pendingApprovals.map((approval) => ( diff --git a/packages/ui/src/components/message-list.tsx b/packages/ui/src/components/message-list.tsx index 5d4599ca..84e712a5 100644 --- a/packages/ui/src/components/message-list.tsx +++ b/packages/ui/src/components/message-list.tsx @@ -7,6 +7,7 @@ import { ToolCallItem } from "@multica/ui/components/tool-call-item"; import { ThinkingItem } from "@multica/ui/components/thinking-item"; import { CompactionItem } from "@multica/ui/components/compaction-item"; import { MessageSourceIcon } from "@multica/ui/components/message-source-icon"; +import { LoadingIndicator } from "@multica/ui/components/loading-indicator"; import { cn, getTextContent } from "@multica/ui/lib/utils"; import type { Message } from "@multica/store"; import type { ContentBlock, ToolCall, ThinkingContent } from "@multica/sdk"; @@ -62,9 +63,16 @@ function toRunningMessage(tc: ToolCall, agentId: string): Message { interface MessageListProps { messages: Message[] streamingIds: Set + isLoading?: boolean + hasPendingApprovals?: boolean } -export const MessageList = memo(function MessageList({ messages, streamingIds }: MessageListProps) { +export const MessageList = memo(function MessageList({ + messages, + streamingIds, + isLoading = false, + hasPendingApprovals = false, +}: MessageListProps) { // Build a set of toolCallIds that already have a toolResult message, // so we don't render duplicate items from the assistant's toolCall blocks const resolvedToolCallIds = useMemo(() => { @@ -125,7 +133,7 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }: )}
{isStreaming ? ( @@ -155,6 +163,12 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
) })} + {isLoading && !hasPendingApprovals && ( + 0 ? "streaming" : "generating"} + className="px-2" + /> + )}
) })