diff --git a/packages/views/chat/components/chat-message-list.tsx b/packages/views/chat/components/chat-message-list.tsx index 5652b1972..326bd7d97 100644 --- a/packages/views/chat/components/chat-message-list.tsx +++ b/packages/views/chat/components/chat-message-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef } from "react"; +import { useMemo, useState, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { cn } from "@multica/ui/lib/utils"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; @@ -35,23 +35,30 @@ export function ChatMessageList({ const fadeStyle = useScrollFade(scrollRef); useAutoScroll(scrollRef); - // Once the assistant message for this pending task has landed in the - // messages list, AssistantMessage owns its rendering — suppress the live - // timeline to avoid rendering the same content in two places during the - // invalidate → refetch window. const pendingAlreadyPersisted = !!pendingTaskId && messages.some( (m) => m.role === "assistant" && m.task_id === pendingTaskId, ); - // Live timeline for the in-flight task. useRealtimeSync keeps this cache - // current via setQueryData on task:message events. - const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted; - const { data: liveTaskMessages } = useQuery({ - ...taskMessagesOptions(pendingTaskId ?? ""), - enabled: showLiveTimeline, - }); - const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem); - const hasLive = showLiveTimeline && liveTimeline.length > 0; + // Synthesize a virtual assistant message for the in-flight task so it flows + // through the same MessageBubble path as persisted messages. Keying by + // `task-` means the persisted message that replaces it reconciles into + // the same DOM subtree instead of unmounting + remounting — which caused a + // visible jump + re-render when streaming finished. + const items = useMemo(() => { + if (!pendingTaskId || pendingAlreadyPersisted) return messages; + const pending: ChatMessage = { + id: `pending-${pendingTaskId}`, + chat_session_id: "", + role: "assistant", + content: "", + task_id: pendingTaskId, + created_at: new Date().toISOString(), + }; + return [...messages, pending]; + }, [messages, pendingTaskId, pendingAlreadyPersisted]); + + const showWaitingSpinner = + isWaiting && !pendingTaskId && !pendingAlreadyPersisted; return (
@@ -60,15 +67,10 @@ export function ChatMessageList({ * views doesn't jolt the reading width. px-5 is a touch tighter * than issue-detail's px-8 because the chat window can be narrow. */}
- {messages.map((msg) => ( - + {items.map((msg) => ( + ))} - {hasLive && ( -
- -
- )} - {isWaiting && !hasLive && !pendingAlreadyPersisted && ( + {showWaitingSpinner && ( )}
@@ -76,6 +78,13 @@ export function ChatMessageList({ ); } +function bubbleKey(msg: ChatMessage): string { + // Keying assistant messages by task_id lets a virtual "pending" entry and + // the persisted message that replaces it share React identity. + if (msg.role === "assistant" && msg.task_id) return `task-${msg.task_id}`; + return msg.id; +} + /** * Placeholder shown while `chat_message` for a session is being fetched * (initial refresh, or switching to an un-cached session). Shape roughly