From 9a275c97def15185854a0cbf8325a5aed871b348 Mon Sep 17 00:00:00 2001 From: Nathan Neitman Date: Sun, 24 May 2026 20:27:07 -0400 Subject: [PATCH] fix: virtualize cline chat message list with react-virtuoso Replaces the full-DOM message list in cline-agent-chat-panel with react-virtuoso so long sessions no longer render every message as a live DOM node. Also wraps ClineChatMessageItem in React.memo so unchanged messages skip re-renders during streaming. --- .../cline-agent-chat-panel.test.tsx | 142 +++++------------- .../detail-panels/cline-agent-chat-panel.tsx | 83 +++++----- .../detail-panels/cline-chat-message-item.tsx | 10 +- 3 files changed, 81 insertions(+), 154 deletions(-) diff --git a/web-ui/src/components/detail-panels/cline-agent-chat-panel.test.tsx b/web-ui/src/components/detail-panels/cline-agent-chat-panel.test.tsx index 02caa485c..d50c32733 100644 --- a/web-ui/src/components/detail-panels/cline-agent-chat-panel.test.tsx +++ b/web-ui/src/components/detail-panels/cline-agent-chat-panel.test.tsx @@ -8,6 +8,31 @@ import type { ClineChatMessage } from "@/hooks/use-cline-chat-session"; import type { RuntimeTaskHookActivity, RuntimeTaskSessionSummary } from "@/runtime/types"; import { resetWorkspaceMetadataStore, setTaskWorkspaceSnapshot } from "@/stores/workspace-metadata-store"; +vi.mock("react-virtuoso", () => ({ + Virtuoso: ({ + data, + itemContent, + components, + context, + className, + style, + }: { + data: unknown[]; + itemContent: (index: number, item: unknown) => React.ReactNode; + components?: { Footer?: React.ComponentType<{ context?: unknown }> }; + context?: unknown; + className?: string; + style?: React.CSSProperties; + }) => ( +
+ {data.map((item, index) => ( +
{itemContent(index, item)}
+ ))} + {components?.Footer ? : null} +
+ ), +})); + function createSummary( state: RuntimeTaskSessionSummary["state"], latestHookActivity: RuntimeTaskHookActivity | null = null, @@ -36,54 +61,6 @@ function renderPanel(root: Root, panel: ReactElement): void { root.render({panel}); } -function getMessageList(container: HTMLElement): HTMLDivElement { - const messageList = container.querySelector("div.overflow-y-auto"); - expect(messageList).toBeInstanceOf(HTMLDivElement); - if (!(messageList instanceof HTMLDivElement)) { - throw new Error("Expected chat message list."); - } - return messageList; -} - -function mockScrollMetrics( - element: HTMLDivElement, - initialValues: { scrollHeight: number; clientHeight: number; scrollTop: number }, -): { - getScrollTop: () => number; - setScrollHeight: (value: number) => void; - setScrollTop: (value: number) => void; -} { - let currentScrollHeight = initialValues.scrollHeight; - const currentClientHeight = initialValues.clientHeight; - let currentScrollTop = initialValues.scrollTop; - - Object.defineProperty(element, "scrollHeight", { - configurable: true, - get: () => currentScrollHeight, - }); - Object.defineProperty(element, "clientHeight", { - configurable: true, - get: () => currentClientHeight, - }); - Object.defineProperty(element, "scrollTop", { - configurable: true, - get: () => currentScrollTop, - set: (value: number) => { - currentScrollTop = value; - }, - }); - - return { - getScrollTop: () => currentScrollTop, - setScrollHeight: (value: number) => { - currentScrollHeight = value; - }, - setScrollTop: (value: number) => { - currentScrollTop = value; - }, - }; -} - describe("ClineAgentChatPanel", () => { let container: HTMLDivElement; let root: Root; @@ -385,7 +362,7 @@ describe("ClineAgentChatPanel", () => { expect(image).toBeInstanceOf(HTMLImageElement); }); - it("keeps the message list pinned to the bottom while new content streams in", async () => { + it("renders newly streamed messages as they arrive", async () => { const initialMessages: ClineChatMessage[] = [ { id: "assistant-1", @@ -409,13 +386,7 @@ describe("ClineAgentChatPanel", () => { await Promise.resolve(); }); - const messageList = getMessageList(container); - const scroll = mockScrollMetrics(messageList, { - scrollHeight: 200, - clientHeight: 100, - scrollTop: 100, - }); - scroll.setScrollHeight(260); + expect(container.textContent).toContain("First reply"); await act(async () => { renderPanel( @@ -430,10 +401,11 @@ describe("ClineAgentChatPanel", () => { await Promise.resolve(); }); - expect(scroll.getScrollTop()).toBe(260); + expect(container.textContent).toContain("First reply"); + expect(container.textContent).toContain("Second reply"); }); - it("stops auto-scroll while the user is reading older messages and re-enables it at the bottom", async () => { + it("renders all messages across multiple content updates", async () => { const initialMessages: ClineChatMessage[] = [ { id: "assistant-1", @@ -463,20 +435,6 @@ describe("ClineAgentChatPanel", () => { await Promise.resolve(); }); - const messageList = getMessageList(container); - const scroll = mockScrollMetrics(messageList, { - scrollHeight: 200, - clientHeight: 100, - scrollTop: 100, - }); - - scroll.setScrollTop(20); - await act(async () => { - messageList.dispatchEvent(new Event("scroll", { bubbles: true })); - await Promise.resolve(); - }); - - scroll.setScrollHeight(260); await act(async () => { renderPanel( root, @@ -490,15 +448,6 @@ describe("ClineAgentChatPanel", () => { await Promise.resolve(); }); - expect(scroll.getScrollTop()).toBe(20); - - scroll.setScrollTop(160); - await act(async () => { - messageList.dispatchEvent(new Event("scroll", { bubbles: true })); - await Promise.resolve(); - }); - - scroll.setScrollHeight(320); await act(async () => { renderPanel( root, @@ -512,7 +461,9 @@ describe("ClineAgentChatPanel", () => { await Promise.resolve(); }); - expect(scroll.getScrollTop()).toBe(320); + expect(container.textContent).toContain("First reply"); + expect(container.textContent).toContain("Second reply"); + expect(container.textContent).toContain("Third reply"); }); it("shows the thinking indicator while assistant text is streaming", async () => { @@ -1069,7 +1020,7 @@ describe("ClineAgentChatPanel", () => { expect(onSendMessage).toHaveBeenCalledWith("task-1", "Keep acting", { mode: "act" }); }); - it("keeps chat pinned to bottom when action footer appears", async () => { + it("renders action footer buttons when task enters review state", async () => { const messages: ClineChatMessage[] = [ { id: "assistant-1", @@ -1089,27 +1040,6 @@ describe("ClineAgentChatPanel", () => { deletions: 1, }); - await act(async () => { - renderPanel( - root, - messages} - showMoveToTrash={false} - />, - ); - await Promise.resolve(); - }); - - const messageList = getMessageList(container); - const scroll = mockScrollMetrics(messageList, { - scrollHeight: 200, - clientHeight: 100, - scrollTop: 100, - }); - scroll.setScrollHeight(240); - await act(async () => { renderPanel( root, @@ -1127,7 +1057,9 @@ describe("ClineAgentChatPanel", () => { await Promise.resolve(); }); - expect(scroll.getScrollTop()).toBe(240); + expect(container.textContent).toContain("Commit"); + expect(container.textContent).toContain("Open PR"); + expect(container.textContent).toContain("Move Card To Done"); }); it("does not show commit actions when the review workspace is clean", async () => { diff --git a/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx b/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx index 1416b6e1b..cc7a170d4 100644 --- a/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx +++ b/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx @@ -13,6 +13,7 @@ import React, { useRef, useState, } from "react"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { ClineChatComposer } from "@/components/detail-panels/cline-chat-composer"; import { ClineChatMessageItem } from "@/components/detail-panels/cline-chat-message-item"; @@ -56,6 +57,21 @@ const ClineCreditLimitNotice = React.memo(function ClineCreditLimitNotice() { ); }); +type ChatFooterContext = { + showAgentProgressIndicator: boolean; + isCreditLimitNoticeVisible: boolean; +}; + +const ChatFooter = React.memo(function ChatFooter({ context }: { context?: ChatFooterContext }) { + if (!context) return null; + return ( + <> + {context.showAgentProgressIndicator ? : null} + {context.isCreditLimitNoticeVisible ? : null} + + ); +}); + export interface ClineAgentChatPanelHandle { appendToDraft: (text: string) => void; sendText: (text: string) => Promise; @@ -141,7 +157,6 @@ export const ClineAgentChatPanel = React.forwardRef(null); + const virtuosoRef = useRef(null); // TODO: Persist per-task mode immediately when toggled so page refresh restores unsent mode changes. const modeByTaskIdRef = useRef>(new Map()); const [composerError, setComposerError] = useState(null); - const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); const [isSavingModel, setIsSavingModel] = useState(false); const isCreditLimitNoticeVisible = summary?.latestHookActivity?.notificationType === "credit_limit"; const [mode, setMode] = useState(() => { @@ -222,45 +236,16 @@ export const ClineAgentChatPanel = React.forwardRef { - const remainingDistance = container.scrollHeight - container.scrollTop - container.clientHeight; - return remainingDistance <= BOTTOM_LOCK_THRESHOLD_PX; - }, []); - - const handleMessageListScroll = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) { - return; - } - const nextIsAutoScrollEnabled = isPinnedToBottom(container); - setIsAutoScrollEnabled((currentValue) => - currentValue === nextIsAutoScrollEnabled ? currentValue : nextIsAutoScrollEnabled, - ); - }, [isPinnedToBottom]); - useLayoutEffect(() => { - const container = scrollContainerRef.current; - if (!container || !isAutoScrollEnabled) { - return; + if (showActionFooter) { + virtuosoRef.current?.scrollToIndex({ index: "LAST", behavior: "auto" }); } - container.scrollTop = container.scrollHeight; - }, [ - isAutoScrollEnabled, - messages, - showAgentProgressIndicator, - showActionFooter, - showReviewActions, - showCancelAutomaticAction, - ]); + }, [showActionFooter]); useEffect(() => { setComposerError(null); }, [taskId]); - useEffect(() => { - setIsAutoScrollEnabled(true); - }, [taskId]); - useEffect(() => { const persistedMode = modeByTaskIdRef.current.get(taskId); const nextMode = persistedMode ?? summary?.mode ?? defaultMode; @@ -412,17 +397,23 @@ export const ClineAgentChatPanel = React.forwardRef -
- {messages.map((message) => ( - - ))} - {showAgentProgressIndicator ? : null} - {isCreditLimitNoticeVisible ? : null} -
+ 0 ? messages.length - 1 : 0} + computeItemKey={(_, message) => message.id} + itemContent={(_, message) => ( +
+ +
+ )} + followOutput="smooth" + atBottomThreshold={BOTTOM_LOCK_THRESHOLD_PX} + context={{ showAgentProgressIndicator, isCreditLimitNoticeVisible }} + components={{ Footer: ChatFooter }} + /> {panelError ? (
{panelError} diff --git a/web-ui/src/components/detail-panels/cline-chat-message-item.tsx b/web-ui/src/components/detail-panels/cline-chat-message-item.tsx index 2177af690..2e9ef9328 100644 --- a/web-ui/src/components/detail-panels/cline-chat-message-item.tsx +++ b/web-ui/src/components/detail-panels/cline-chat-message-item.tsx @@ -1,6 +1,6 @@ import * as Collapsible from "@radix-ui/react-collapsible"; import { Brain, ChevronDown, ChevronRight, XCircle } from "lucide-react"; -import { type ReactElement, useEffect, useMemo, useRef, useState } from "react"; +import React, { type ReactElement, useEffect, useMemo, useRef, useState } from "react"; import { formatToolInputForDisplay, getToolDisplay, @@ -175,7 +175,11 @@ function ReasoningMessageBlock({ message }: { message: ClineChatMessage }): Reac ); } -export function ClineChatMessageItem({ message }: { message: ClineChatMessage }): ReactElement { +export const ClineChatMessageItem = React.memo(function ClineChatMessageItem({ + message, +}: { + message: ClineChatMessage; +}): ReactElement { if (message.role === "tool") { return ; } @@ -209,4 +213,4 @@ export function ClineChatMessageItem({ message }: { message: ClineChatMessage }) {message.content}
); -} +});