From 71cc646951be16fd5be3bb2df7fe103e90c5083d Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 24 Apr 2026 02:01:12 +0800 Subject: [PATCH] fix(chat): prevent UI flicker when streaming response finalizes (#1583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live timeline was rendered in a separate
from the persisted messages list. When the streamed task finished and its ChatMessage landed, the live
unmounted and a new 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 --- .../chat/components/chat-message-list.tsx | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/views/chat/components/chat-message-list.tsx b/packages/views/chat/components/chat-message-list.tsx index 5652b1972..4667c0142 100644 --- a/packages/views/chat/components/chat-message-list.tsx +++ b/packages/views/chat/components/chat-message-list.tsx @@ -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 (
@@ -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. */}
- {messages.map((msg) => ( - + {displayMessages.map((msg) => ( + ))} - {hasLive && ( -
- -
- )} - {isWaiting && !hasLive && !pendingAlreadyPersisted && ( + {isWaiting && !pendingTaskId && ( )}
@@ -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 (
@@ -133,13 +135,15 @@ function MessageBubble({ message }: { message: ChatMessage }) { ); } - return ; + return ; } function AssistantMessage({ message, + isPending, }: { message: ChatMessage; + isPending?: boolean; }) { const taskId = message.task_id; @@ -157,11 +161,13 @@ function AssistantMessage({
{timeline.length > 0 ? ( - ) : ( + ) : message.content ? (
{message.content}
- )} + ) : isPending ? ( + + ) : null}
); }