Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,8 @@ export class ClineProvider
const viewStateDisposable = webviewView.onDidChangeViewState(() => {
if (this.view?.visible) {
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
} else {
this.logWebviewHiddenDiagnostics()
}
})

Expand All @@ -857,6 +859,8 @@ export class ClineProvider
const visibilityDisposable = webviewView.onDidChangeVisibility(() => {
if (this.view?.visible) {
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
} else {
this.logWebviewHiddenDiagnostics()
}
})

Expand Down Expand Up @@ -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
Expand Down
58 changes: 50 additions & 8 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))

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

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<typeof vi.fn>).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 },
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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([])
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))

Expand Down
14 changes: 13 additions & 1 deletion webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1480,6 +1485,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
],
)

const computeMessageKey = useCallback(
(index: number, messageOrGroup: ClineMessage) => `${messageOrGroup.ts}-${index}`,
[],
)

// Function to handle mode switching
const switchToNextMode = useCallback(() => {
const allModes = getAllModes(customModes)
Expand Down Expand Up @@ -1639,7 +1649,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
ref={virtuosoRef}
key={task.ts}
className="scrollable grow overflow-y-scroll mb-1"
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
computeItemKey={computeMessageKey}
defaultItemHeight={CHAT_DEFAULT_ITEM_HEIGHT}
increaseViewportBy={CHAT_VIEWPORT_BUFFER}
data={groupedMessages}
itemContent={itemContent}
followOutput={followOutputCallback}
Expand Down
61 changes: 60 additions & 1 deletion webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import type { SuggestionItem } from "@roo-code/types"

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

const mockVirtuosoState = vi.hoisted(() => ({
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"
Expand Down Expand Up @@ -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 (
<div data-testid="virtuoso-item-list">
{data.map((item, index) => (
<div key={item.ts} data-testid={`virtuoso-item-${index}`}>
<div key={computeItemKey?.(index, item) ?? item.ts} data-testid={`virtuoso-item-${index}`}>
{itemContent(index, item)}
</div>
))}
Expand Down Expand Up @@ -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())

Expand Down
Loading