Merge pull request #196 from multica-ai/fix/chat-input-multiline

fix(ui): preserve newlines in chat input multiline text
This commit is contained in:
Naiyuan Qing 2026-02-15 11:08:09 +08:00 committed by GitHub
commit 59f8802f7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 92 additions and 50 deletions

View file

@ -284,8 +284,8 @@ export default function Layout() {
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent side="top">
<DropdownMenuItem onClick={handleLogout}>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="size-4" />
Log out
</DropdownMenuItem>

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

@ -5,7 +5,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
/** Skeleton placeholder matching MessageList layout, shown while reconnecting */
export function ChatSkeleton() {
return (
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
<div className="container px-4 py-6 space-y-6">
{/* Assistant message */}
<div className="flex justify-start">
<div className="w-full p-1 px-2.5 space-y-2">

View file

@ -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({
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
{isLoadingHistory && messages.length === 0 ? (
<div className="px-4 py-6 max-w-4xl mx-auto">
<div className="container px-4 py-6">
{/* User bubble */}
<div className="flex justify-end my-2">
<Skeleton className="h-8 w-[30%] rounded-md" />
@ -209,17 +208,14 @@ export function ChatView({
</div>
</div>
)}
<MessageList messages={messages} streamingIds={streamingIds} />
{isLoading && streamingIds.size === 0 && pendingApprovals.length === 0 && (
<div className="relative px-4 sm:px-10 max-w-4xl mx-auto">
<div className="flex items-center gap-2 py-1 px-2.5 text-muted-foreground">
<Spinner className="text-xs" />
<span className="text-xs">Generating...</span>
</div>
</div>
)}
<MessageList
messages={messages}
streamingIds={streamingIds}
isLoading={isLoading}
hasPendingApprovals={pendingApprovals.length > 0}
/>
{pendingApprovals.length > 0 && (
<div className="relative px-4 max-w-4xl mx-auto">
<div className="container relative px-4">
{pendingApprovals.map((approval) => (
<ExecApprovalItem
key={approval.approvalId}

View file

@ -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<LoadingVariant, string> = {
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 (
<div className={cn("flex items-center gap-2 py-1 text-muted-foreground", className)}>
<Spinner className="text-xs" />
<span className="text-xs">{VARIANT_TEXT[variant]}</span>
</div>
);
}

View file

@ -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 (
<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 +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 (
<Markdown mode={mode} onUrlClick={onUrlClick} onFileClick={onFileClick}>
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
}
// Empty content - return null, let parent handle loading indicator
if (blocks.length === 0) {
return (
<div className="flex items-center gap-2 py-1 text-muted-foreground">
<Spinner className="text-xs" />
<span className="text-xs">Generating...</span>
</div>
)
return <></>
}
const indicator = (
<div className="absolute bottom-1 left-6 flex items-center gap-2 py-1 text-muted-foreground">
<Spinner className="text-xs" />
<span className="text-xs">Generating...</span>
</div>
)
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}
</>
)
}

View file

@ -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<string>
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 (
<div className="relative p-6 px-4 sm:px-10 max-w-4xl mx-auto">
<div className="container relative p-6">
{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 }:
)}
<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-2 px-4 my-2" : "w-full p-2 my-2"
)}
>
{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>
)}
@ -146,6 +163,12 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
</div>
)
})}
{isLoading && !hasPendingApprovals && (
<LoadingIndicator
variant={streamingIds.size > 0 ? "streaming" : "generating"}
className="px-2"
/>
)}
</div>
)
})

View file

@ -257,7 +257,7 @@
}
@utility container {
@apply w-full max-w-5xl mx-auto;
@apply w-full max-w-4xl mx-auto;
}
@layer base {