mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-02 20:11:09 +02:00
Compare commits
2 Commits
main
...
agent/walt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd64f7fdcf | ||
|
|
398355ae2f |
101
packages/views/chat/components/chat-message-list.test.tsx
Normal file
101
packages/views/chat/components/chat-message-list.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user