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:
commit
59f8802f7f
8 changed files with 92 additions and 50 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
30
packages/ui/src/components/loading-indicator.tsx
Normal file
30
packages/ui/src/components/loading-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@
|
|||
}
|
||||
|
||||
@utility container {
|
||||
@apply w-full max-w-5xl mx-auto;
|
||||
@apply w-full max-w-4xl mx-auto;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue