mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
fix(chat): prevent UI flicker when streaming response finalizes (#1583)
The live timeline was rendered in a separate <div> from the persisted messages list. When the streamed task finished and its ChatMessage landed, the live <div> unmounted and a new <MessageBubble> mounted — two different DOM elements showing the same content. useAutoScroll's ResizeObserver + MutationObserver fired on both the unmount and the mount, causing the visible jump-then-re-render. Merge the two paths: inject a synthetic assistant message with the pending task_id while streaming, and key every assistant bubble by task_id. When the real message arrives (same task_id), React preserves the DOM element across the invalidate → refetch window — no remount, no double scroll, no flicker. Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
This commit is contained in:
@@ -35,23 +35,26 @@ 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.
|
||||
// While a task is in flight and its assistant message hasn't landed yet,
|
||||
// inject a synthetic placeholder carrying the same task_id. AssistantMessage
|
||||
// reads the live timeline from the shared taskMessagesOptions cache (fed by
|
||||
// WS), so the synthetic bubble renders exactly what the persisted bubble
|
||||
// will. Because MessageBubble is keyed by task_id when present, the DOM
|
||||
// element is preserved when the real message arrives — no unmount/remount,
|
||||
// no double scroll-to-bottom, no visible jump.
|
||||
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;
|
||||
const displayMessages: ChatMessage[] = pendingTaskId && !pendingAlreadyPersisted
|
||||
? [...messages, {
|
||||
id: `pending-${pendingTaskId}`,
|
||||
chat_session_id: messages[messages.length - 1]?.chat_session_id ?? "",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
task_id: pendingTaskId,
|
||||
created_at: new Date().toISOString(),
|
||||
}]
|
||||
: messages;
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} style={fadeStyle} className="flex-1 overflow-y-auto">
|
||||
@@ -60,15 +63,14 @@ 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. */}
|
||||
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
{displayMessages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.role === "assistant" && msg.task_id ? `task-${msg.task_id}` : msg.id}
|
||||
message={msg}
|
||||
isPending={!!pendingTaskId && msg.task_id === pendingTaskId && !pendingAlreadyPersisted}
|
||||
/>
|
||||
))}
|
||||
{hasLive && (
|
||||
<div className="w-full space-y-1.5">
|
||||
<TimelineView items={liveTimeline} />
|
||||
</div>
|
||||
)}
|
||||
{isWaiting && !hasLive && !pendingAlreadyPersisted && (
|
||||
{isWaiting && !pendingTaskId && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
@@ -116,7 +118,7 @@ function toTimelineItem(m: TaskMessagePayload): ChatTimelineItem {
|
||||
|
||||
// ─── Message bubbles ─────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
function MessageBubble({ message, isPending }: { message: ChatMessage; isPending?: boolean }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -133,13 +135,15 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
return <AssistantMessage message={message} />;
|
||||
return <AssistantMessage message={message} isPending={isPending} />;
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
message,
|
||||
isPending,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
isPending?: boolean;
|
||||
}) {
|
||||
const taskId = message.task_id;
|
||||
|
||||
@@ -157,11 +161,13 @@ function AssistantMessage({
|
||||
<div className="w-full space-y-1.5">
|
||||
{timeline.length > 0 ? (
|
||||
<TimelineView items={timeline} />
|
||||
) : (
|
||||
) : message.content ? (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
) : isPending ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user