diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 14b8ed306..18f7cef6d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -848,6 +848,8 @@ export class ClineProvider const viewStateDisposable = webviewView.onDidChangeViewState(() => { if (this.view?.visible) { this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + } else { + this.logWebviewHiddenDiagnostics() } }) @@ -857,6 +859,8 @@ export class ClineProvider const visibilityDisposable = webviewView.onDidChangeVisibility(() => { if (this.view?.visible) { this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + } else { + this.logWebviewHiddenDiagnostics() } }) @@ -2913,6 +2917,21 @@ export class ClineProvider return this.clineStack[this.clineStack.length - 1] } + private logWebviewHiddenDiagnostics(): void { + const task = this.getCurrentTask() + if (!task || task.abort || task.abandoned) { + return + } + this.log( + `[Zoo Code] Webview hidden during active task.\n` + + ` taskId: ${task.taskId}\n` + + ` messageCount: ${task.clineMessages.length}\n` + + ` stackDepth: ${this.clineStack.length}\n` + + ` timestamp: ${new Date().toISOString()}\n` + + `If the panel appears gray after this, share this log with support@zoocode.dev`, + ) + } + public getRecentTasks(): string[] { if (this.recentTasksCache) { return this.recentTasksCache diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1ff4c1288..92a372904 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -366,7 +366,7 @@ describe("ClineProvider", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext let mockOutputChannel: vscode.OutputChannel - let mockWebviewView: vscode.WebviewView + let mockWebviewView: any let mockPostMessage: any let updateGlobalStateSpy: any @@ -445,7 +445,7 @@ describe("ClineProvider", () => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), - } as unknown as vscode.WebviewView + } provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) @@ -505,6 +505,48 @@ describe("ClineProvider", () => { expect(mockWebviewView.webview.html).toContain("") }) + describe("logWebviewHiddenDiagnostics", () => { + let visibilityCallback: () => void + + beforeEach(async () => { + // Capture the visibility callback registered during resolveWebviewView + mockWebviewView.onDidChangeVisibility = vi.fn().mockImplementation((cb: () => void) => { + visibilityCallback = cb + return { dispose: vi.fn() } + }) + // @ts-ignore - accessing private property for testing + provider.view = mockWebviewView + await provider.resolveWebviewView(mockWebviewView) + ;(mockOutputChannel.appendLine as ReturnType).mockClear() + }) + + test("does not log when no task is active", () => { + // view becomes hidden with no task on the stack + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + visibilityCallback() + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + }) + + test("does not log when the active task is aborted", async () => { + const task = new Task(defaultTaskOptions) + Object.defineProperty(task, "taskId", { value: "aborted-task", writable: true }) + task.abort = true + await provider.addClineToStack(task) + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + visibilityCallback() + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + }) + + test("logs task state to output channel when an active task is running", async () => { + const task = new Task(defaultTaskOptions) + Object.defineProperty(task, "taskId", { value: "running-task", writable: true }) + await provider.addClineToStack(task) + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + visibilityCallback() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining("running-task")) + }) + }) + test("resolveWebviewView sets up webview correctly in development mode even if local server is not running", async () => { provider = new ClineProvider( { ...mockContext, extensionMode: vscode.ExtensionMode.Development }, @@ -2059,7 +2101,7 @@ describe("Project MCP Settings", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext let mockOutputChannel: vscode.OutputChannel - let mockWebviewView: vscode.WebviewView + let mockWebviewView: any let mockPostMessage: any beforeEach(async () => { @@ -2113,7 +2155,7 @@ describe("Project MCP Settings", () => { visible: true, onDidDispose: vi.fn(), onDidChangeVisibility: vi.fn(), - } as unknown as vscode.WebviewView + } ;(vscode.window as any).activeTextEditor = undefined ;(vscode.workspace.getWorkspaceFolder as any).mockReset() provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) @@ -2376,7 +2418,7 @@ describe("ClineProvider - Router Models", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext let mockOutputChannel: vscode.OutputChannel - let mockWebviewView: vscode.WebviewView + let mockWebviewView: any let mockPostMessage: any beforeEach(() => { @@ -2435,7 +2477,7 @@ describe("ClineProvider - Router Models", () => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), - } as unknown as vscode.WebviewView + } if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -2687,7 +2729,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext let mockOutputChannel: vscode.OutputChannel - let mockWebviewView: vscode.WebviewView + let mockWebviewView: any let mockPostMessage: any let defaultTaskOptions: TaskOptions @@ -2756,7 +2798,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { return { dispose: vi.fn() } }), onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), - } as unknown as vscode.WebviewView + } provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c44e54c78..0be22cfbc 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -55,6 +55,11 @@ export interface ChatViewRef { } export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit. +const CHAT_DEFAULT_ITEM_HEIGHT = 180 +const CHAT_VIEWPORT_BUFFER = { + top: 600, + bottom: 800, +} as const const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 @@ -1480,6 +1485,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction `${messageOrGroup.ts}-${index}`, + [], + ) + // Function to handle mode switching const switchToNextMode = useCallback(() => { const allModes = getAllModes(customModes) @@ -1639,7 +1649,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ + lastConfig: null as { + computeItemKey?: (index: number, item: ClineMessage) => React.Key + defaultItemHeight?: number + increaseViewportBy?: number | { top?: number; bottom?: number } + } | null, +})) + // Define minimal types needed for testing interface ClineMessage { type: "say" | "ask" @@ -88,14 +96,26 @@ vi.mock("react-virtuoso", () => ({ Virtuoso: function MockVirtuoso({ data, itemContent, + computeItemKey, + defaultItemHeight, + increaseViewportBy, }: { data: ClineMessage[] itemContent: (index: number, item: ClineMessage) => React.ReactNode + computeItemKey?: (index: number, item: ClineMessage) => React.Key + defaultItemHeight?: number + increaseViewportBy?: number | { top?: number; bottom?: number } }) { + mockVirtuosoState.lastConfig = { + computeItemKey, + defaultItemHeight, + increaseViewportBy, + } + return (
{data.map((item, index) => ( -
+
{itemContent(index, item)}
))} @@ -481,6 +501,45 @@ describe("ChatView - Sound Playing Tests", () => { }) }) +describe("ChatView - Virtualization Configuration", () => { + beforeEach(() => { + vi.clearAllMocks() + mockVirtuosoState.lastConfig = null + }) + + it("keeps the off-screen render buffer tight for chat rows", async () => { + renderChatView() + + const taskTs = Date.now() - 100 + const rowTs = Date.now() + + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: taskTs, + text: "Initial task", + }, + { + type: "say", + say: "text", + ts: rowTs, + text: "Visible row", + }, + ], + }) + + await waitFor(() => { + expect(mockVirtuosoState.lastConfig).not.toBeNull() + }) + + expect(mockVirtuosoState.lastConfig?.defaultItemHeight).toBe(180) + expect(mockVirtuosoState.lastConfig?.increaseViewportBy).toEqual({ top: 600, bottom: 800 }) + expect(mockVirtuosoState.lastConfig?.computeItemKey?.(1, { type: "say", ts: rowTs })).toBe(`${rowTs}-1`) + }) +}) + describe("ChatView - Focus Grabbing Tests", () => { beforeEach(() => vi.clearAllMocks())