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-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/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..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 { Spinner } from "@multica/ui/components/spinner"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; import type { Message } from "@multica/store"; @@ -145,7 +144,7 @@ export function ChatView({
{isLoadingHistory && messages.length === 0 ? ( -
+
{/* User bubble */}
@@ -209,17 +208,14 @@ export function ChatView({
)} - - {isLoading && streamingIds.size === 0 && pendingApprovals.length === 0 && ( -
-
- - Generating... -
-
- )} + 0} + /> {pendingApprovals.length > 0 && ( -
+
{pendingApprovals.map((approval) => ( = { + 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 282ca65a..aad2f330 100644 --- a/packages/ui/src/components/markdown/StreamingMarkdown.tsx +++ b/packages/ui/src/components/markdown/StreamingMarkdown.tsx @@ -1,11 +1,11 @@ import * as React from 'react' import { Markdown, type RenderMode } from './Markdown' -import { Spinner } from '@multica/ui/components/spinner' export interface StreamingMarkdownProps { content: string isStreaming: boolean mode?: RenderMode + className?: string onUrlClick?: (url: string) => void onFileClick?: (path: string) => void } @@ -132,23 +132,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 +175,7 @@ export function StreamingMarkdown({ content, isStreaming, mode = 'minimal', + className, onUrlClick, onFileClick }: StreamingMarkdownProps): React.JSX.Element { @@ -186,28 +189,17 @@ export function StreamingMarkdown({ // Not streaming - use simple Markdown (no block splitting needed) if (!isStreaming) { return ( - + {content} ) } + // Empty content - return null, let parent handle loading indicator if (blocks.length === 0) { - return ( -
- - Generating... -
- ) + return <> } - const indicator = ( -
- - Generating... -
- ) - return ( <> {blocks.map((block, i) => { @@ -222,12 +214,12 @@ export function StreamingMarkdown({ key={key} content={block.content} mode={mode} + className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} /> ) })} - {indicator} ) } diff --git a/packages/ui/src/components/message-list.tsx b/packages/ui/src/components/message-list.tsx index 2ff46d5b..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(() => { @@ -78,7 +86,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 +133,22 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }: )}
{isStreaming ? ( - + ) : ( - + {text} )} @@ -146,6 +163,12 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
) })} + {isLoading && !hasPendingApprovals && ( + 0 ? "streaming" : "generating"} + className="px-2" + /> + )}
) }) 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 {