Skip to content

Commit 8c65b87

Browse files
roomote[bot]taltasedelauna
committed
[Fix] Chat window runs out of memory when transcript grows large (#153)
* fix: reduce chat transcript pre-rendering * feat(ClineProvider): adding simple diagnostics for grey screen debug --------- Co-authored-by: Toray Altas <6816042+taltas@users.noreply.github.com> Co-authored-by: T <taltas@users.noreply.github.com> Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> Co-authored-by: Elliott de Launay <edelauna@gmail.com>
1 parent 507f4d4 commit 8c65b87

4 files changed

Lines changed: 142 additions & 10 deletions

File tree

src/core/webview/ClineProvider.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,8 @@ export class ClineProvider
848848
const viewStateDisposable = webviewView.onDidChangeViewState(() => {
849849
if (this.view?.visible) {
850850
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
851+
} else {
852+
this.logWebviewHiddenDiagnostics()
851853
}
852854
})
853855

@@ -857,6 +859,8 @@ export class ClineProvider
857859
const visibilityDisposable = webviewView.onDidChangeVisibility(() => {
858860
if (this.view?.visible) {
859861
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
862+
} else {
863+
this.logWebviewHiddenDiagnostics()
860864
}
861865
})
862866

@@ -2913,6 +2917,21 @@ export class ClineProvider
29132917
return this.clineStack[this.clineStack.length - 1]
29142918
}
29152919

2920+
private logWebviewHiddenDiagnostics(): void {
2921+
const task = this.getCurrentTask()
2922+
if (!task || task.abort || task.abandoned) {
2923+
return
2924+
}
2925+
this.log(
2926+
`[Zoo Code] Webview hidden during active task.\n` +
2927+
` taskId: ${task.taskId}\n` +
2928+
` messageCount: ${task.clineMessages.length}\n` +
2929+
` stackDepth: ${this.clineStack.length}\n` +
2930+
` timestamp: ${new Date().toISOString()}\n` +
2931+
`If the panel appears gray after this, share this log with support@zoocode.dev`,
2932+
)
2933+
}
2934+
29162935
public getRecentTasks(): string[] {
29172936
if (this.recentTasksCache) {
29182937
return this.recentTasksCache

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ describe("ClineProvider", () => {
366366
let provider: ClineProvider
367367
let mockContext: vscode.ExtensionContext
368368
let mockOutputChannel: vscode.OutputChannel
369-
let mockWebviewView: vscode.WebviewView
369+
let mockWebviewView: any
370370
let mockPostMessage: any
371371
let updateGlobalStateSpy: any
372372

@@ -445,7 +445,7 @@ describe("ClineProvider", () => {
445445
return { dispose: vi.fn() }
446446
}),
447447
onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })),
448-
} as unknown as vscode.WebviewView
448+
}
449449

450450
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
451451

@@ -505,6 +505,48 @@ describe("ClineProvider", () => {
505505
expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")
506506
})
507507

508+
describe("logWebviewHiddenDiagnostics", () => {
509+
let visibilityCallback: () => void
510+
511+
beforeEach(async () => {
512+
// Capture the visibility callback registered during resolveWebviewView
513+
mockWebviewView.onDidChangeVisibility = vi.fn().mockImplementation((cb: () => void) => {
514+
visibilityCallback = cb
515+
return { dispose: vi.fn() }
516+
})
517+
// @ts-ignore - accessing private property for testing
518+
provider.view = mockWebviewView
519+
await provider.resolveWebviewView(mockWebviewView)
520+
;(mockOutputChannel.appendLine as ReturnType<typeof vi.fn>).mockClear()
521+
})
522+
523+
test("does not log when no task is active", () => {
524+
// view becomes hidden with no task on the stack
525+
Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true })
526+
visibilityCallback()
527+
expect(mockOutputChannel.appendLine).not.toHaveBeenCalled()
528+
})
529+
530+
test("does not log when the active task is aborted", async () => {
531+
const task = new Task(defaultTaskOptions)
532+
Object.defineProperty(task, "taskId", { value: "aborted-task", writable: true })
533+
task.abort = true
534+
await provider.addClineToStack(task)
535+
Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true })
536+
visibilityCallback()
537+
expect(mockOutputChannel.appendLine).not.toHaveBeenCalled()
538+
})
539+
540+
test("logs task state to output channel when an active task is running", async () => {
541+
const task = new Task(defaultTaskOptions)
542+
Object.defineProperty(task, "taskId", { value: "running-task", writable: true })
543+
await provider.addClineToStack(task)
544+
Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true })
545+
visibilityCallback()
546+
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining("running-task"))
547+
})
548+
})
549+
508550
test("resolveWebviewView sets up webview correctly in development mode even if local server is not running", async () => {
509551
provider = new ClineProvider(
510552
{ ...mockContext, extensionMode: vscode.ExtensionMode.Development },
@@ -2059,7 +2101,7 @@ describe("Project MCP Settings", () => {
20592101
let provider: ClineProvider
20602102
let mockContext: vscode.ExtensionContext
20612103
let mockOutputChannel: vscode.OutputChannel
2062-
let mockWebviewView: vscode.WebviewView
2104+
let mockWebviewView: any
20632105
let mockPostMessage: any
20642106

20652107
beforeEach(async () => {
@@ -2113,7 +2155,7 @@ describe("Project MCP Settings", () => {
21132155
visible: true,
21142156
onDidDispose: vi.fn(),
21152157
onDidChangeVisibility: vi.fn(),
2116-
} as unknown as vscode.WebviewView
2158+
}
21172159
;(vscode.window as any).activeTextEditor = undefined
21182160
;(vscode.workspace.getWorkspaceFolder as any).mockReset()
21192161
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
@@ -2376,7 +2418,7 @@ describe("ClineProvider - Router Models", () => {
23762418
let provider: ClineProvider
23772419
let mockContext: vscode.ExtensionContext
23782420
let mockOutputChannel: vscode.OutputChannel
2379-
let mockWebviewView: vscode.WebviewView
2421+
let mockWebviewView: any
23802422
let mockPostMessage: any
23812423

23822424
beforeEach(() => {
@@ -2435,7 +2477,7 @@ describe("ClineProvider - Router Models", () => {
24352477
return { dispose: vi.fn() }
24362478
}),
24372479
onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })),
2438-
} as unknown as vscode.WebviewView
2480+
}
24392481

24402482
if (!TelemetryService.hasInstance()) {
24412483
TelemetryService.createInstance([])
@@ -2687,7 +2729,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
26872729
let provider: ClineProvider
26882730
let mockContext: vscode.ExtensionContext
26892731
let mockOutputChannel: vscode.OutputChannel
2690-
let mockWebviewView: vscode.WebviewView
2732+
let mockWebviewView: any
26912733
let mockPostMessage: any
26922734
let defaultTaskOptions: TaskOptions
26932735

@@ -2756,7 +2798,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
27562798
return { dispose: vi.fn() }
27572799
}),
27582800
onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })),
2759-
} as unknown as vscode.WebviewView
2801+
}
27602802

27612803
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
27622804

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ export interface ChatViewRef {
5555
}
5656

5757
export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
58+
const CHAT_DEFAULT_ITEM_HEIGHT = 180
59+
const CHAT_VIEWPORT_BUFFER = {
60+
top: 600,
61+
bottom: 800,
62+
} as const
5863

5964
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
6065

@@ -1493,6 +1498,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14931498
],
14941499
)
14951500

1501+
const computeMessageKey = useCallback(
1502+
(index: number, messageOrGroup: ClineMessage) => `${messageOrGroup.ts}-${index}`,
1503+
[],
1504+
)
1505+
14961506
// Function to handle mode switching
14971507
const switchToNextMode = useCallback(() => {
14981508
const allModes = getAllModes(customModes)
@@ -1652,7 +1662,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
16521662
ref={virtuosoRef}
16531663
key={task.ts}
16541664
className="scrollable grow overflow-y-scroll mb-1"
1655-
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
1665+
computeItemKey={computeMessageKey}
1666+
defaultItemHeight={CHAT_DEFAULT_ITEM_HEIGHT}
1667+
increaseViewportBy={CHAT_VIEWPORT_BUFFER}
16561668
data={groupedMessages}
16571669
itemContent={itemContent}
16581670
followOutput={followOutputCallback}

webview-ui/src/components/chat/__tests__/ChatView.spec.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import type { SuggestionItem } from "@roo-code/types"
1010

1111
import ChatView, { ChatViewProps } from "../ChatView"
1212

13+
const mockVirtuosoState = vi.hoisted(() => ({
14+
lastConfig: null as {
15+
computeItemKey?: (index: number, item: ClineMessage) => React.Key
16+
defaultItemHeight?: number
17+
increaseViewportBy?: number | { top?: number; bottom?: number }
18+
} | null,
19+
}))
20+
1321
// Define minimal types needed for testing
1422
interface ClineMessage {
1523
type: "say" | "ask"
@@ -88,14 +96,26 @@ vi.mock("react-virtuoso", () => ({
8896
Virtuoso: function MockVirtuoso({
8997
data,
9098
itemContent,
99+
computeItemKey,
100+
defaultItemHeight,
101+
increaseViewportBy,
91102
}: {
92103
data: ClineMessage[]
93104
itemContent: (index: number, item: ClineMessage) => React.ReactNode
105+
computeItemKey?: (index: number, item: ClineMessage) => React.Key
106+
defaultItemHeight?: number
107+
increaseViewportBy?: number | { top?: number; bottom?: number }
94108
}) {
109+
mockVirtuosoState.lastConfig = {
110+
computeItemKey,
111+
defaultItemHeight,
112+
increaseViewportBy,
113+
}
114+
95115
return (
96116
<div data-testid="virtuoso-item-list">
97117
{data.map((item, index) => (
98-
<div key={item.ts} data-testid={`virtuoso-item-${index}`}>
118+
<div key={computeItemKey?.(index, item) ?? item.ts} data-testid={`virtuoso-item-${index}`}>
99119
{itemContent(index, item)}
100120
</div>
101121
))}
@@ -481,6 +501,45 @@ describe("ChatView - Sound Playing Tests", () => {
481501
})
482502
})
483503

504+
describe("ChatView - Virtualization Configuration", () => {
505+
beforeEach(() => {
506+
vi.clearAllMocks()
507+
mockVirtuosoState.lastConfig = null
508+
})
509+
510+
it("keeps the off-screen render buffer tight for chat rows", async () => {
511+
renderChatView()
512+
513+
const taskTs = Date.now() - 100
514+
const rowTs = Date.now()
515+
516+
mockPostMessage({
517+
clineMessages: [
518+
{
519+
type: "say",
520+
say: "task",
521+
ts: taskTs,
522+
text: "Initial task",
523+
},
524+
{
525+
type: "say",
526+
say: "text",
527+
ts: rowTs,
528+
text: "Visible row",
529+
},
530+
],
531+
})
532+
533+
await waitFor(() => {
534+
expect(mockVirtuosoState.lastConfig).not.toBeNull()
535+
})
536+
537+
expect(mockVirtuosoState.lastConfig?.defaultItemHeight).toBe(180)
538+
expect(mockVirtuosoState.lastConfig?.increaseViewportBy).toEqual({ top: 600, bottom: 800 })
539+
expect(mockVirtuosoState.lastConfig?.computeItemKey?.(1, { type: "say", ts: rowTs })).toBe(`${rowTs}-1`)
540+
})
541+
})
542+
484543
describe("ChatView - Focus Grabbing Tests", () => {
485544
beforeEach(() => vi.clearAllMocks())
486545

0 commit comments

Comments
 (0)