Files
multica/packages/views/chat/components/chat-message-list.test.tsx
Naiyuan Qing 2e8578acbd fix(chat): stop remounting the live timeline on every streamed task message (MUL-3960) (#4845)
* 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>

* 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>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-02 16:12:50 +08:00

102 lines
3.7 KiB
TypeScript

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