Compare commits

...

1 Commits

Author SHA1 Message Date
Lambda
5e7d575919 fix(chat): prevent UI flicker when streaming response finalizes
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.
2026-04-24 01:57:20 +08:00

View File

@@ -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>
);
}