Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
dd64f7fdcf perf(chat): memoize MessageBubble so streamed messages skip untouched rows
Follow-up to the MUL-3960 remount fix: each task:message still re-rendered
every visible history row through itemContent. Message objects are
referentially stable and isPending is a boolean, so a shallow memo makes
the persisted history inert during streaming — only the live footer
reconciles per message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:04:07 +08:00
Naiyuan Qing
398355ae2f fix(chat): stop remounting the live timeline on every streamed task message (MUL-3960)
The Virtuoso components prop was built inline, so every render produced new
Header/Footer component types and React unmounted + remounted the entire live
timeline subtree. During task streaming that meant every task:message event
tore down and rebuilt thousands of rows and re-parsed all Markdown, freezing
the renderer for seconds at a time on long agent runs.

- Hoist Header/Footer to module scope and pass per-render data through
  Virtuoso's context prop (re-render instead of remount).
- Memoize buildTimeline on the task-messages cache array identity (live
  timeline and persisted AssistantMessage).
- Render message text through MemoizedMarkdown so unchanged content skips
  the markdown re-parse; only the streaming tail re-renders.

Regression tests assert the footer DOM node survives a streamed message and
that a user-collapsed process fold stays closed (both failed before the fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-02 15:45:19 +08:00
2 changed files with 214 additions and 41 deletions

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import { chatKeys } from "@multica/core/chat/queries";
import type { TaskMessagePayload } from "@multica/core/types";
import enChat from "../../locales/en/chat.json";
import { ChatMessageList } from "./chat-message-list";
const TEST_RESOURCES = { en: { chat: enChat } };
const TASK_ID = "6af44cbe-80ab-4dfe-b07d-bd3cfd588f4d";
function taskMsg(
seq: number,
type: TaskMessagePayload["type"],
extra: Partial<TaskMessagePayload> = {},
): TaskMessagePayload {
return { task_id: TASK_ID, seq, type, ...extra } as TaskMessagePayload;
}
// A streaming timeline whose middle (tool steps) is non-empty, so the live
// footer renders the "N steps" outer fold.
const INITIAL_MESSAGES: TaskMessagePayload[] = [
taskMsg(0, "text", { content: "Looking into it. " }),
taskMsg(1, "tool_use", { tool: "Bash", input: { command: "go test ./..." } }),
taskMsg(2, "tool_result", { tool: "Bash", output: "ok" }),
];
function renderList(qc: QueryClient) {
qc.setQueryData(chatKeys.taskMessages(TASK_ID), INITIAL_MESSAGES);
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>
<ChatMessageList
messages={[]}
pendingTask={{ task_id: TASK_ID, status: "running" }}
availability={undefined}
/>
</QueryClientProvider>
</I18nProvider>,
);
}
function pushTaskMessage(qc: QueryClient, msg: TaskMessagePayload) {
// Mirrors useRealtimeSync's task:message handler: a new array lands in the
// shared task-messages cache on every streamed message.
act(() => {
qc.setQueryData<TaskMessagePayload[]>(
chatKeys.taskMessages(TASK_ID),
(old = []) => [...old, msg],
);
});
}
describe("ChatMessageList live timeline (MUL-3960 regression)", () => {
// The live footer is passed to Virtuoso through `components`. If that prop
// is rebuilt inline on render, every streamed task:message unmounts and
// remounts the whole footer subtree — re-parsing all Markdown and rebuilding
// thousands of DOM rows, which froze the renderer during long agent runs.
it("does not remount the live timeline when a streamed message arrives", async () => {
const qc = new QueryClient();
renderList(qc);
const foldTrigger = await screen.findByText("2 steps");
const footerBefore = foldTrigger.closest("div");
pushTaskMessage(
qc,
taskMsg(3, "tool_use", { tool: "Read", input: { file_path: "/tmp/x" } }),
);
// The fold re-renders in place: same DOM node, updated count.
const updatedTrigger = await screen.findByText("3 steps");
expect(updatedTrigger.closest("div")).toBe(footerBefore);
expect(document.contains(foldTrigger)).toBe(true);
});
it("keeps the process fold closed by the user across streamed messages", async () => {
const qc = new QueryClient();
renderList(qc);
// Streaming defaults the fold open; the user closes it.
const foldTrigger = await screen.findByText("2 steps");
expect(screen.getByText("Bash")).toBeInTheDocument();
act(() => {
foldTrigger.click();
});
expect(screen.queryByText("Bash")).not.toBeInTheDocument();
pushTaskMessage(
qc,
taskMsg(3, "tool_use", { tool: "Read", input: { file_path: "/tmp/x" } }),
);
// Before the fix the footer remounted, useState re-seeded defaultOpen and
// the fold sprang back open on every streamed message.
await screen.findByText("3 steps");
expect(screen.queryByText("Bash")).not.toBeInTheDocument();
});
});

View File

@@ -1,9 +1,9 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { Virtuoso } from "react-virtuoso";
import { Virtuoso, type Components } from "react-virtuoso";
import { cn } from "@multica/ui/lib/utils";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
@@ -20,11 +20,16 @@ import {
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle, Copy } from "lucide-react";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { isTaskMessageTaskId, taskMessagesOptions } from "@multica/core/chat/queries";
import { Markdown } from "@multica/views/common/markdown";
import { MemoizedMarkdown } from "@multica/views/common/markdown";
import { copyText } from "@multica/ui/lib/clipboard";
import { AttachmentList } from "../../issues/components/comment-card";
import type { AgentAvailability } from "@multica/core/agents";
import type { ChatMessage, ChatPendingTask, TaskFailureReason } from "@multica/core/types";
import type {
ChatMessage,
ChatPendingTask,
TaskFailureReason,
TaskMessagePayload,
} from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
import { buildTimeline } from "../../common/task-transcript";
@@ -50,6 +55,66 @@ interface ChatMessageListProps {
onLoadOlderMessages?: () => void;
}
// ─── Virtuoso chrome ─────────────────────────────────────────────────────
//
// Header/Footer MUST be stable component references (module scope), never
// inline arrows in the `components` prop: an inline `components={{ Footer:
// () => … }}` creates a new component *type* every render, so React unmounts
// and remounts the whole Header/Footer subtree each time. During task
// streaming that tore down and rebuilt the entire live timeline — every row
// and every Markdown parse — on every `task:message` event, freezing the
// renderer for seconds at a time (MUL-3960). Per-render data flows through
// Virtuoso's `context` prop instead, which reaches these components as an
// ordinary prop (re-render, not remount).
interface ChatListContext {
isFetchingOlderMessages: boolean;
hasLive: boolean;
liveTimeline: ChatTimelineItem[];
showStatusPill: boolean;
pendingTask: ChatPendingTask | null | undefined;
liveTaskMessages: readonly TaskMessagePayload[] | undefined;
availability: AgentAvailability | undefined;
}
function ChatListHeader({ context }: { context?: ChatListContext }) {
const { t } = useT("chat");
return (
<div className="mx-auto w-full max-w-4xl px-5 pt-4">
{context?.isFetchingOlderMessages && (
<div className="text-center text-xs text-muted-foreground">
{t(($) => $.message_list.loading_older)}
</div>
)}
</div>
);
}
function ChatListFooter({ context }: { context?: ChatListContext }) {
if (!context) return null;
return (
<div className="mx-auto w-full max-w-4xl px-5 pb-4 space-y-4">
{context.hasLive && (
<div className="w-full space-y-1.5">
<TimelineView items={context.liveTimeline} isStreaming />
</div>
)}
{context.showStatusPill && context.pendingTask && (
<TaskStatusPill
pendingTask={context.pendingTask}
taskMessages={context.liveTaskMessages ?? []}
availability={context.availability}
/>
)}
</div>
);
}
const LIST_COMPONENTS: Components<ChatMessage, ChatListContext> = {
Header: ChatListHeader,
Footer: ChatListFooter,
};
export function ChatMessageList({
messages,
pendingTask,
@@ -67,7 +132,6 @@ export function ChatMessageList({
setScrollContainerEl(node);
}, []);
const fadeStyle = useScrollFade(scrollRef);
const { t } = useT("chat");
const pendingTaskId = pendingTask?.task_id ?? null;
@@ -87,13 +151,29 @@ export function ChatMessageList({
...taskMessagesOptions(pendingTaskId ?? ""),
enabled: canFetchLiveTimeline,
});
const liveTimeline: ChatTimelineItem[] = buildTimeline(liveTaskMessages ?? []);
// Memoized on the cache array identity: mergeTaskMessagesBySeq preserves
// the array reference when a duplicate event arrives, so this recomputes
// only when a genuinely new message lands — not on unrelated re-renders.
const liveTimeline: ChatTimelineItem[] = useMemo(
() => buildTimeline(liveTaskMessages ?? []),
[liveTaskMessages],
);
const hasLive = showLiveTimeline && liveTimeline.length > 0;
const showStatusPill = !!pendingTaskId && !pendingAlreadyPersisted && !!pendingTask;
const totalCount = messages.length + (hasLive || showStatusPill ? 1 : 0);
const firstIndex = totalCount > 0 ? firstItemIndex : 0;
const listContext: ChatListContext = {
isFetchingOlderMessages,
hasLive,
liveTimeline,
showStatusPill,
pendingTask,
liveTaskMessages,
availability,
};
return (
<div
ref={setScrollContainerRef}
@@ -120,31 +200,8 @@ export function ChatMessageList({
}
}}
computeItemKey={(_, msg) => msg.id}
components={{
Header: () => (
<div className="mx-auto w-full max-w-4xl px-5 pt-4">
{isFetchingOlderMessages && (
<div className="text-center text-xs text-muted-foreground">{t(($) => $.message_list.loading_older)}</div>
)}
</div>
),
Footer: () => (
<div className="mx-auto w-full max-w-4xl px-5 pb-4 space-y-4">
{hasLive && (
<div className="w-full space-y-1.5">
<TimelineView items={liveTimeline} isStreaming />
</div>
)}
{showStatusPill && pendingTask && (
<TaskStatusPill
pendingTask={pendingTask}
taskMessages={liveTaskMessages ?? []}
availability={availability}
/>
)}
</div>
),
}}
context={listContext}
components={LIST_COMPONENTS}
itemContent={(_, msg) => (
<div className="mx-auto w-full max-w-4xl px-5 py-2">
<MessageBubble
@@ -188,7 +245,18 @@ export function ChatMessageSkeleton() {
// ─── Message bubbles ─────────────────────────────────────────────────────
function MessageBubble({ message, isPending }: { message: ChatMessage; isPending: boolean }) {
// memo: every streamed task:message re-renders ChatMessageList, and with it
// every VISIBLE row via itemContent. Message objects are referentially
// stable for unchanged messages and isPending is a boolean, so a shallow
// memo skips reconciling rows the stream didn't touch — the persisted
// history stays inert while only the live footer updates.
const MessageBubble = memo(function MessageBubble({
message,
isPending,
}: {
message: ChatMessage;
isPending: boolean;
}) {
if (message.role === "user") {
return (
<div className="flex justify-end">
@@ -198,7 +266,7 @@ function MessageBubble({ message, isPending }: { message: ChatMessage; isPending
* Neutralise prose's leading/trailing margin so single-line
* bubbles stay as compact as the plain-text version used to. */}
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<Markdown attachments={message.attachments}>{message.content}</Markdown>
<MemoizedMarkdown attachments={message.attachments}>{message.content}</MemoizedMarkdown>
</div>
<AttachmentList
attachments={message.attachments}
@@ -211,7 +279,7 @@ function MessageBubble({ message, isPending }: { message: ChatMessage; isPending
}
return <AssistantMessage message={message} isPending={isPending} />;
}
});
function AssistantMessage({
message,
@@ -231,7 +299,11 @@ function AssistantMessage({
enabled: canFetchTaskMessages,
});
const timeline: ChatTimelineItem[] = buildTimeline(taskMessages ?? []);
// Same memoization rationale as the live timeline in ChatMessageList.
const timeline: ChatTimelineItem[] = useMemo(
() => buildTimeline(taskMessages ?? []),
[taskMessages],
);
// Failure bubble path: when the server's FailTask wrote a failure
// chat_message (failure_reason set), render a destructive bubble with the
@@ -254,7 +326,7 @@ function AssistantMessage({
<TimelineView items={timeline} attachments={message.attachments} />
) : (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown attachments={message.attachments}>{message.content}</Markdown>
<MemoizedMarkdown attachments={message.attachments}>{message.content}</MemoizedMarkdown>
</div>
)}
<AttachmentList
@@ -446,9 +518,9 @@ function TimelineView({
<>
{preface.length > 0 && (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown attachments={attachments}>
<MemoizedMarkdown attachments={attachments}>
{preface.map((t) => t.content ?? "").join("")}
</Markdown>
</MemoizedMarkdown>
</div>
)}
{middle.length > 0 && (
@@ -460,9 +532,9 @@ function TimelineView({
)}
{final.length > 0 && (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown attachments={attachments}>
<MemoizedMarkdown attachments={attachments}>
{final.map((t) => t.content ?? "").join("")}
</Markdown>
</MemoizedMarkdown>
</div>
)}
</>
@@ -521,7 +593,7 @@ function MiddleTextRow({
}) {
return (
<div className="py-0.5 text-xs text-muted-foreground prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<Markdown attachments={attachments}>{item.content ?? ""}</Markdown>
<MemoizedMarkdown attachments={attachments}>{item.content ?? ""}</MemoizedMarkdown>
</div>
);
}