diff --git a/.changeset/agent-manager-virtualization.md b/.changeset/agent-manager-virtualization.md new file mode 100644 index 00000000000..8a8af3366ea --- /dev/null +++ b/.changeset/agent-manager-virtualization.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +fix: reduce GPU usage in Agent Manager with message virtualization diff --git a/webview-ui/src/kilocode/agent-manager/components/AgentManagerApp.css b/webview-ui/src/kilocode/agent-manager/components/AgentManagerApp.css index e8a13a3469c..1067935cab5 100644 --- a/webview-ui/src/kilocode/agent-manager/components/AgentManagerApp.css +++ b/webview-ui/src/kilocode/agent-manager/components/AgentManagerApp.css @@ -331,7 +331,7 @@ /* Messages Area */ .am-messages-container { flex: 1; - overflow-y: auto; + overflow: hidden; padding: 0; display: flex; flex-direction: column; @@ -339,8 +339,8 @@ .am-messages-list { flex: 1; - display: flex; - flex-direction: column; + height: 100%; + overflow-y: auto !important; } .am-message-item { diff --git a/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx b/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx index 43b320b2d6f..e749026dc60 100644 --- a/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx +++ b/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useCallback, useMemo } from "react" import { useAtomValue, useSetAtom } from "jotai" import { useTranslation } from "react-i18next" +import { Virtuoso, VirtuosoHandle } from "react-virtuoso" import { sessionMessagesAtomFamily } from "../state/atoms/messages" import { sessionInputAtomFamily } from "../state/atoms/sessions" import { @@ -64,7 +65,7 @@ export function MessageList({ sessionId }: MessageListProps) { const setInputValue = useSetAtom(sessionInputAtomFamily(sessionId)) const retryFailedMessage = useSetAtom(retryFailedMessageAtom) const removeFromQueue = useSetAtom(removeFromQueueAtom) - const containerRef = useRef(null) + const virtuosoRef = useRef(null) // Combine command and command_output messages into single entries const combinedMessages = useMemo(() => combineCommandSequences(messages), [messages]) @@ -93,17 +94,15 @@ export function MessageList({ sessionId }: MessageListProps) { return info }, [messages]) - // Auto-scroll to bottom when new messages arrive + // Auto-scroll to bottom when new messages arrive using Virtuoso API useEffect(() => { - if (containerRef.current) { - // Use requestAnimationFrame to ensure the DOM has updated - requestAnimationFrame(() => { - if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight - } + if (combinedMessages.length > 0) { + virtuosoRef.current?.scrollToIndex({ + index: combinedMessages.length - 1, + behavior: "smooth", }) } - }, [combinedMessages]) + }, [combinedMessages.length]) const handleSuggestionClick = useCallback( (suggestion: SuggestionItem) => { @@ -137,6 +136,54 @@ export function MessageList({ sessionId }: MessageListProps) { [removeFromQueue], ) + // Combine messages and queued messages for virtualization + const allItems = useMemo(() => { + return [...combinedMessages, ...queue.map((q) => ({ type: "queued" as const, data: q }))] + }, [combinedMessages, queue]) + + // Item content renderer for Virtuoso + const itemContent = useCallback( + (index: number, item: ClineMessage | { type: "queued"; data: QueuedMessage }) => { + // Check if this is a queued message + if ("type" in item && item.type === "queued") { + const queuedMsg = item.data + return ( + + ) + } + + // Regular message + const msg = item as ClineMessage + // isLastCombinedMessage: true for the last regular message, excluding queued user messages + const isLastCombinedMessage = index === combinedMessages.length - 1 + return ( + + ) + }, + [ + combinedMessages.length, + commandExecutionByTs, + handleSuggestionClick, + handleCopyToInput, + sendingMessageId, + handleRetryMessage, + handleDiscardMessage, + ], + ) + if (messages.length === 0 && queue.length === 0) { return (
@@ -147,29 +194,15 @@ export function MessageList({ sessionId }: MessageListProps) { } return ( -
-
- {combinedMessages.map((msg, idx) => ( - - ))} - {/* Display queued messages */} - {queue.map((queuedMsg) => ( - - ))} -
+
+
) } diff --git a/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx b/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx index a5e8d627ae1..8bde8949599 100644 --- a/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx +++ b/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx @@ -4,6 +4,7 @@ import { Provider, createStore } from "jotai" import { MessageList } from "../MessageList" import { sessionMessagesAtomFamily } from "../../state/atoms/messages" import { sessionInputAtomFamily } from "../../state/atoms/sessions" +import { sessionMessageQueueAtomFamily } from "../../state/atoms/messageQueue" import type { ClineMessage } from "@roo-code/types" // Mock react-i18next @@ -29,6 +30,19 @@ vi.mock("../../../../components/ui", () => ({ StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })) +// Mock react-virtuoso - tracks rendered items for testing +vi.mock("react-virtuoso", () => ({ + Virtuoso: ({ data, itemContent }: any) => ( +
+ {data.map((item: any, index: number) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ), +})) + describe("MessageList", () => { const sessionId = "test-session" @@ -293,4 +307,97 @@ describe("MessageList", () => { expect(screen.getByText("messages.tool")).toBeInTheDocument() }) }) + + describe("isLast calculation with queued messages", () => { + it("marks the last regular message as isLast even when queued messages exist", () => { + const store = createStore() + store.set(sessionMessagesAtomFamily(sessionId), [ + { + ts: 1, + type: "say", + say: "text", + text: "First message", + } as ClineMessage, + { + ts: 2, + type: "say", + say: "text", + text: "Second message", + } as ClineMessage, + ]) + store.set(sessionMessageQueueAtomFamily(sessionId), [ + { + id: "queued-1", + sessionId, + content: "Queued message", + status: "queued" as const, + retryCount: 0, + maxRetries: 3, + timestamp: Date.now(), + }, + ]) + + render( + + + , + ) + + // Verify all items are rendered (2 messages + 1 queued) + const virtuosoList = screen.getByTestId("virtuoso-list") + expect(virtuosoList.getAttribute("data-item-count")).toBe("3") + + // Verify all content is rendered + expect(screen.getByText("First message")).toBeInTheDocument() + expect(screen.getByText("Second message")).toBeInTheDocument() + expect(screen.getByText("Queued message")).toBeInTheDocument() + }) + + it("renders queued messages after regular messages in the list", () => { + const store = createStore() + store.set(sessionMessagesAtomFamily(sessionId), [ + { + ts: 1, + type: "say", + say: "text", + text: "Regular message", + } as ClineMessage, + ]) + store.set(sessionMessageQueueAtomFamily(sessionId), [ + { + id: "queued-1", + sessionId, + content: "First queued", + status: "queued" as const, + retryCount: 0, + maxRetries: 3, + timestamp: Date.now(), + }, + { + id: "queued-2", + sessionId, + content: "Second queued", + status: "queued" as const, + retryCount: 0, + maxRetries: 3, + timestamp: Date.now(), + }, + ]) + + render( + + + , + ) + + // Verify order: regular message at index 0, queued at indices 1 and 2 + const item0 = screen.getByTestId("virtuoso-item-0") + const item1 = screen.getByTestId("virtuoso-item-1") + const item2 = screen.getByTestId("virtuoso-item-2") + + expect(item0).toHaveTextContent("Regular message") + expect(item1).toHaveTextContent("First queued") + expect(item2).toHaveTextContent("Second queued") + }) + }) })