diff --git a/.changeset/agent-manager-multi-version-terminal.md b/.changeset/agent-manager-multi-version-terminal.md new file mode 100644 index 00000000000..ce3ee72b300 --- /dev/null +++ b/.changeset/agent-manager-multi-version-terminal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Agent Manager multi-version sessions to wait for pending CLI processes so terminals are available per worktree. 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/pnpm-lock.yaml b/pnpm-lock.yaml index 0984ecd367b..1388dec140e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4979,7 +4979,6 @@ packages: '@lancedb/lancedb@0.21.3': resolution: {integrity: sha512-hfzp498BfcCJ730fV1YGGoXVxRgE+W1n0D0KwanKlbt8bBPSQ6E6Tf8mPXc8rKdAXIRR3o5mTzMG3z3Fda+m3Q==} engines: {node: '>= 18'} - cpu: [x64, arm64] os: [darwin, linux, win32] peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' diff --git a/src/core/kilocode/agent-manager/AgentManagerProvider.ts b/src/core/kilocode/agent-manager/AgentManagerProvider.ts index 5a2d4ee9c38..b1958cf692b 100644 --- a/src/core/kilocode/agent-manager/AgentManagerProvider.ts +++ b/src/core/kilocode/agent-manager/AgentManagerProvider.ts @@ -380,8 +380,10 @@ export class AgentManagerProvider implements vscode.Disposable { */ private waitForPendingSessionToClear(): Promise { return new Promise((resolve) => { - // Check immediately - if no pending session, resolve right away - if (!this.registry.pendingSession) { + const hasPending = () => !!this.registry.pendingSession || this.processHandler?.hasPendingProcess() + + // Check immediately - if no pending session/process, resolve right away + if (!hasPending()) { resolve() return } @@ -391,7 +393,7 @@ export class AgentManagerProvider implements vscode.Disposable { // Poll until pending session clears const checkInterval = setInterval(() => { - if (!this.registry.pendingSession) { + if (!hasPending()) { clearInterval(checkInterval) if (timeoutId) { clearTimeout(timeoutId) diff --git a/src/core/kilocode/agent-manager/CliProcessHandler.ts b/src/core/kilocode/agent-manager/CliProcessHandler.ts index 3d5cc979a47..1a40040a826 100644 --- a/src/core/kilocode/agent-manager/CliProcessHandler.ts +++ b/src/core/kilocode/agent-manager/CliProcessHandler.ts @@ -353,6 +353,10 @@ export class CliProcessHandler { return this.activeSessions.has(sessionId) } + public hasPendingProcess(): boolean { + return this.pendingProcess !== null + } + /** * Write a JSON message to a session's stdin */ diff --git a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts index acd03f0e240..1bdaf93dc81 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -246,6 +246,33 @@ describe("AgentManagerProvider CLI spawning", () => { expect(sessions[0].sessionId).toBe("cli-session-123") }) + it("waits for pending processes to clear before resolving multi-version sequencing", async () => { + vi.useFakeTimers() + + try { + const registry = (provider as any).registry + const processHandler = (provider as any).processHandler + + registry.clearPendingSession() + processHandler.pendingProcess = {} + + let resolved = false + const waitPromise = (provider as any).waitForPendingSessionToClear().then(() => { + resolved = true + }) + + await Promise.resolve() + expect(resolved).toBe(false) + + processHandler.pendingProcess = null + vi.advanceTimersByTime(200) + await waitPromise + expect(resolved).toBe(true) + } finally { + vi.useRealTimers() + } + }) + it("shows existing terminal when selecting a session", () => { const sessionId = "session-terminal" const registry = (provider as any).registry 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") + }) + }) })