diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 2a04cfe8b24..bc8463dcfc2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -901,10 +901,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - template.renderedPartsMounted = false; - }; - templateDisposables.add(this._onDidUpdateViewModel.event(() => { if (!template.currentElement || !this.viewModel?.sessionResource || !isEqual(template.currentElement.sessionResource, this.viewModel.sessionResource)) { this.clearRenderedParts(template); @@ -933,7 +929,17 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer()); + connectionObserver.onDidConnect = () => { + resizeObservation.value = resizeObserver.observe(rowContainer); + }; + connectionObserver.onDidDisconnect = () => { + template.renderedPartsMounted = false; + resizeObservation.clear(); + }; + if (rowContainer.isConnected) { + connectionObserver.onDidConnect(); + } return template; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 26864fda362..d9b7d39ab00 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -3188,6 +3188,17 @@ export class ChatWidget extends Disposable implements IChatWidget { this._layoutListForInputHeight(); } + /** + * Updates the widget's available space after the intrinsic input height changed. + * The input has already laid itself out, so this only resizes the list-side + * surfaces and must not call {@link ChatInputPart.layout}. + */ + layoutForInputHeight(height: number, width: number): void { + width = Math.min(width, this.viewOptions.renderStyle === 'minimal' ? width : 950); + this.bodyDimension = new dom.Dimension(width, height); + this._layoutListForInputHeight(); + } + /** * Re-layout just the list, welcome container, and list container to match * the current input-part height. Called both from {@link layout} and from @@ -3399,4 +3410,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } } +export function layoutChatWidgetForInputHeight(widget: Pick, inputMaxHeight: number | undefined, height: number, width: number): void { + widget.setInputPartMaxHeightOverride(inputMaxHeight); + widget.layoutForInputHeight(height, width); +} + const MIN_LIST_HEIGHT = 50; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 3c1c32bb334..0937398dd03 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -55,7 +55,7 @@ import { LocalChatSessionUri, getChatSessionType, isUntitledChatSession } from ' import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../../common/constants.js'; import { AgentSessionsControl } from '../../agentSessions/agentSessionsControl.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; -import { ChatWidget } from '../../widget/chatWidget.js'; +import { ChatWidget, layoutChatWidgetForInputHeight } from '../../widget/chatWidget.js'; import { ChatViewWelcomeController, IViewWelcomeDelegate } from '../../viewsWelcome/chatViewWelcomeController.js'; import { IChatViewsWelcomeDescriptor } from '../../viewsWelcome/chatViewsWelcome.js'; import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../../services/layout/browser/layoutService.js'; @@ -1089,7 +1089,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._register(autorun(reader => { chatWidget.inputPart.height.read(reader); if (this.sessionsViewerVisible && this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { - this.relayout(); + this.relayoutForInputHeight(); } })); @@ -1452,6 +1452,14 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } } + private relayoutForInputHeight(): void { + if (this.layoutingBody || !this._widget?.visible || !this.lastDimensions) { + return; + } + + this.layoutChatAndSessions(this.lastDimensions.height, this.lastDimensions.width, false); + } + protected override layoutBody(height: number, width: number): void { if (this.layoutingBody) { return; // prevent re-entrancy @@ -1469,7 +1477,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { super.layoutBody(height, width); this.lastDimensions = { height, width }; + this.layoutChatAndSessions(height, width, true); + } + private layoutChatAndSessions(height: number, width: number, layoutInput: boolean): void { let remainingHeight = height; const remainingWidth = width; @@ -1488,10 +1499,15 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // the sessions viewer deduction) so the input can grow freely. As the input // grows, an autorun triggers relayout which shrinks the sessions viewer, // giving the widget more space and converging to the right sizes. - this._widget.setInputPartMaxHeightOverride(this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked ? remainingHeight : undefined); + const inputMaxHeight = this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked ? remainingHeight : undefined; // Chat Widget - this._widget.layout(remainingHeight - heightReduction, remainingWidth - widthReduction); + if (layoutInput) { + this._widget.setInputPartMaxHeightOverride(inputMaxHeight); + this._widget.layout(remainingHeight - heightReduction, remainingWidth - widthReduction); + } else { + layoutChatWidgetForInputHeight(this._widget, inputMaxHeight, remainingHeight - heightReduction, remainingWidth - widthReduction); + } // Remember last dimensions per orientation this.lastDimensionsPerOrientation.set(this.sessionsViewerOrientation, { height, width }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts index fabb6c180fb..332c084c6ff 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; import { Range } from '../../../../../../editor/common/core/range.js'; -import { getImmediateSilentSlashCommandPart } from '../../../browser/widget/chatWidget.js'; +import { getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight } from '../../../browser/widget/chatWidget.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { ChatRequestSlashCommandPart, ChatRequestTextPart, IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; @@ -68,4 +68,19 @@ suite('ChatWidget', () => { undefined, ]); }); + + test('input height changes update the budget without re-laying out the input', () => { + const calls: unknown[] = []; + const target = { + setInputPartMaxHeightOverride: (height: number | undefined) => calls.push(['setInputPartMaxHeightOverride', height]), + layoutForInputHeight: (height: number, width: number) => calls.push(['layoutForInputHeight', height, width]), + }; + + layoutChatWidgetForInputHeight(target, 600, 420, 720); + + assert.deepStrictEqual(calls, [ + ['setInputPartMaxHeightOverride', 600], + ['layoutForInputHeight', 420, 720], + ]); + }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index bd90cd1debb..2805b46e84a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -6,7 +6,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { autorun, constObservable } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -97,6 +97,17 @@ export interface IChatWidgetFixtureOptions { * response. */ readonly turnStatusPills?: ChatTurnStatusPillsSetting; + readonly onRendered?: (handle: IChatWidgetFixtureHandle) => void; + /** Selects the input-height consumer used by the ResizeObserver harness. */ + readonly hostLayoutMode?: 'none' | 'listOnly' | 'stackedFull' | 'stackedTargeted'; +} + +interface IChatWidgetFixtureHandle { + readonly inputPart: ChatInputPart; + readonly listWidget: ChatListWidget; + readonly model: ChatModel; + readonly width: number; + readonly addTerminalConfirmation: (request: ReturnType, command: string) => void; } function makeFileDiff(change: IFixtureFileChange): IEditSessionEntryDiff { @@ -355,7 +366,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options inputPart.element.classList.toggle('chat-input-hidden', options.inputVisible === false); const listContainer = dom.$('.interactive-list'); - listContainer.style.flex = '1 1 auto'; + listContainer.style.flex = options.hostLayoutMode ? '0 0 auto' : '1 1 auto'; listContainer.style.minHeight = '0'; listContainer.style.position = 'relative'; // Prepend the list before the input so the visual order matches production. @@ -378,6 +389,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options }, }, )); + listWidget.setViewModel(viewModel); listWidget.setVisible(true); listWidget.refresh(); @@ -385,6 +397,60 @@ export async function renderChatWidget(context: ComponentFixtureContext, options const listHeight = 420; listWidget.layout(listHeight, width); listWidget.scrollTop = 0; + + if (options.hostLayoutMode && options.hostLayoutMode !== 'none') { + let layouting = false; + disposableStore.add(autorun(reader => { + const inputHeight = inputPart.height.read(reader); + if (layouting) { + return; + } + + layouting = true; + try { + if (options.hostLayoutMode === 'stackedFull') { + // Mirrors ChatViewPane's stacked-sessions convergence path: + // the host synchronously lays out the input again. + inputPart.setMaxHeight(Math.max(0, height - 50)); + inputPart.layout(width); + } + + const contentHeight = options.hostLayoutMode === 'stackedFull' || options.hostLayoutMode === 'stackedTargeted' + ? Math.max(0, Math.max(116, inputHeight) - inputHeight) + : Math.max(0, height - inputHeight); + listContainer.style.height = `${contentHeight}px`; + listContainer.dataset['expectedHeight'] = String(contentHeight); + listWidget.layout(contentHeight, width); + } finally { + layouting = false; + } + })); + } + + options.onRendered?.({ + inputPart, + listWidget, + model, + width, + addTerminalConfirmation: (request, command) => { + model.acceptResponseProgress(request, new ChatToolInvocation( + { + invocationMessage: new MarkdownString(`Running \`${command}\``), + pastTenseMessage: new MarkdownString(`Ran \`${command}\``), + confirmationMessages: { title: 'Run diagnostic command?', message: new MarkdownString(`\`${command}\``) }, + toolSpecificData: { + kind: 'terminal', + commandLine: { original: command }, + language: 'pwsh', + }, + }, + fixtureToolData, + generateUuid(), + undefined, + { command }, + )); + }, + }); } const SIMPLE_QA: IFixtureMessage[] = [ @@ -559,10 +625,152 @@ const CODE_BLOCK_IN_LIST: IFixtureMessage[] = [ }, ]; +async function renderResizeObserverLoopHarness(context: ComponentFixtureContext, hostLayoutMode: IChatWidgetFixtureOptions['hostLayoutMode']): Promise { + const targetWindow = context.container.ownerDocument.defaultView; + if (!targetWindow) { + throw new Error('ResizeObserver harness requires a window'); + } + + let handle: IChatWidgetFixtureHandle | undefined; + await renderChatWidget(context, { + messages: [{ + user: [ + 'Investigate ResizeObserver re-entry.', + '', + 'Context (text/plain; no binary upload):', + 'Issue #316501 tracks chat list and input resize-observer loop warnings.', + ].join('\n'), + assistant: [{ + kind: 'markdown', + text: 'The mocked chat harness is ready.', + }], + }], + width: 720, + height: 600, + renderStyle: 'default', + hostLayoutMode, + onRendered: value => handle = value, + }); + + if (!handle) { + throw new Error('ResizeObserver harness did not initialize'); + } + const fixtureHandle = handle; + + const controls = dom.$('.resize-observer-loop-harness'); + const runButton = dom.append(controls, dom.$('button.resize-observer-loop-run')); + runButton.type = 'button'; + runButton.textContent = 'Run 20-turn burst'; + const status = dom.append(controls, dom.$('span.resize-observer-loop-status')); + status.role = 'status'; + status.textContent = 'Ready'; + const warnings = dom.append(controls, dom.$('span.resize-observer-loop-warnings')); + warnings.textContent = 'Warnings: 0'; + controls.style.position = 'absolute'; + controls.style.top = '8px'; + controls.style.right = '8px'; + controls.style.zIndex = '100'; + controls.style.display = 'flex'; + controls.style.gap = '8px'; + controls.style.alignItems = 'center'; + controls.style.padding = '6px 8px'; + controls.style.background = 'var(--vscode-editorWidget-background)'; + controls.style.border = '1px solid var(--vscode-widget-border)'; + context.container.style.position = 'relative'; + context.container.appendChild(controls); + + let warningCount = 0; + context.disposableStore.add(dom.addDisposableListener(targetWindow, dom.EventType.ERROR, event => { + if (event instanceof ErrorEvent && event.message.includes('ResizeObserver loop')) { + warningCount++; + warnings.textContent = `Warnings: ${warningCount}`; + warnings.dataset['lastAttribution'] = dom.getRecentDisposableResizeObserverAttributionForLoopError(event.message) ?? event.message; + status.textContent = 'Captured ResizeObserver warning'; + } + })); + + const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); + const runBurst = async () => { + runButton.disabled = true; + status.textContent = 'Adding queued turns...'; + const responses = []; + + for (let index = 1; index <= 20; index++) { + const prompt = [ + `Queued prompt ${index}`, + '', + 'Context (text/plain; no binary upload):', + ...Array.from({ length: 12 }, (_, line) => `Resize stress sample ${index}.${line + 1}: ${'layout '.repeat(index % 5 + 1)}`), + ].join('\n'); + + fixtureHandle.inputPart.setValue(prompt, true); + fixtureHandle.inputPart.layout(fixtureHandle.width); + + const request = fixtureHandle.model.addRequest(makeUserMessage(prompt), { variables: [] }, 0); + fixtureHandle.model.acceptResponseProgress(request, { + kind: 'progressMessage', + content: new MarkdownString(`Processing queued prompt ${index}...`), + }); + if (index === 1) { + fixtureHandle.addTerminalConfirmation(request, 'git status --short'); + } + responses.push(request.response!); + + fixtureHandle.listWidget.refresh(); + await nextFrame(); + + fixtureHandle.inputPart.setValue('', true); + fixtureHandle.inputPart.layout(fixtureHandle.width); + fixtureHandle.model.acceptResponseProgress(request, { + kind: 'markdownContent', + content: new MarkdownString(`Mock streamed output ${index}\n\n${'- response line\n'.repeat(index % 7 + 1)}`), + }); + fixtureHandle.listWidget.refresh(); + await nextFrame(); + } + + status.textContent = 'Completing mocked responses...'; + for (const response of responses) { + response.complete(); + fixtureHandle.listWidget.refresh(); + await nextFrame(); + } + + status.textContent = warningCount > 0 + ? 'Completed with ResizeObserver warning' + : 'Completed without warning'; + runButton.disabled = false; + }; + + context.disposableStore.add(dom.addDisposableListener(runButton, dom.EventType.CLICK, () => { + void runBurst(); + })); +} + export default defineThemedFixtureGroup({ path: 'chat/widget/' }, { SimpleQA: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: SIMPLE_QA }) }), Streaming: defineComponentFixture({ labels: { kind: 'animated' }, render: ctx => renderChatWidget(ctx, { messages: STREAMING }) }), PendingToolApproval: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL }) }), + ResizeObserverLoopHarness: defineComponentFixture({ + labels: { kind: 'animated' }, + virtualTime: { enabled: false }, + render: context => renderResizeObserverLoopHarness(context, 'stackedFull'), + }), + ResizeObserverLoopListOnly: defineComponentFixture({ + labels: { kind: 'animated' }, + virtualTime: { enabled: false }, + render: context => renderResizeObserverLoopHarness(context, 'listOnly'), + }), + ResizeObserverLoopStackedTargeted: defineComponentFixture({ + labels: { kind: 'animated' }, + virtualTime: { enabled: false }, + render: context => renderResizeObserverLoopHarness(context, 'stackedTargeted'), + }), + ResizeObserverLoopNoHostLayout: defineComponentFixture({ + labels: { kind: 'animated' }, + virtualTime: { enabled: false }, + render: context => renderResizeObserverLoopHarness(context, 'none'), + }), CodeBlockInList: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: CODE_BLOCK_IN_LIST }) }), bugs: defineThemedFixtureGroup({ 'issue-309796-missing-backslash': defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: ISSUE_309796_MISSING_BACKSLASH }) }), diff --git a/test/componentFixtures/playwright/tests/chatResizeObserver.spec.ts b/test/componentFixtures/playwright/tests/chatResizeObserver.spec.ts new file mode 100644 index 00000000000..83fb6de49fb --- /dev/null +++ b/test/componentFixtures/playwright/tests/chatResizeObserver.spec.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect, test } from '@playwright/test'; +import { openFixture } from './utils.js'; + +const scenarios = [ + { name: 'no host layout', fixture: 'ResizeObserverLoopNoHostLayout', expectedWarnings: 0 }, + { name: 'list-only layout', fixture: 'ResizeObserverLoopListOnly', expectedWarnings: 0 }, + { name: 'stacked full layout', fixture: 'ResizeObserverLoopHarness', expectedWarnings: 0 }, + { name: 'stacked targeted layout', fixture: 'ResizeObserverLoopStackedTargeted', expectedWarnings: 0 }, +] as const; + +for (const scenario of scenarios) { + test(`runs the mocked chat resize-observer burst harness with ${scenario.name}`, async ({ page }) => { + const resizeObserverErrors: string[] = []; + page.on('pageerror', error => { + if (error.message.includes('ResizeObserver loop')) { + resizeObserverErrors.push(error.message); + } + }); + + await openFixture(page, `chat/widget/chatWidget/${scenario.fixture}/Dark`, '.resize-observer-loop-harness'); + await page.getByRole('button', { name: 'Run 20-turn burst' }).click(); + await expect(page.getByRole('status')).toContainText(/Completed/, { timeout: 30_000 }); + const warningText = await page.locator('.resize-observer-loop-warnings').textContent(); + const lastAttribution = await page.locator('.resize-observer-loop-warnings').getAttribute('data-last-attribution'); + console.log(`[chat-resize-harness:${scenario.name}] ${warningText}; page errors: ${resizeObserverErrors.length}; last attribution: ${lastAttribution}`); + const warningCount = Number(warningText?.replace('Warnings: ', '')); + expect(warningCount).toBe(scenario.expectedWarnings); + if (scenario.name === 'stacked targeted layout') { + const geometry = await page.locator('.interactive-list').evaluate(element => { + const list = element.querySelector('.monaco-list'); + return { + expectedHeight: Number((element as HTMLElement).dataset['expectedHeight']), + containerHeight: element.getBoundingClientRect().height, + listHeight: list?.getBoundingClientRect().height, + }; + }); + expect(geometry.containerHeight).toBeCloseTo(geometry.expectedHeight); + expect(geometry.listHeight).toBeCloseTo(geometry.expectedHeight); + } + + await test.info().attach('resize-observer-errors.json', { + body: Buffer.from(JSON.stringify(resizeObserverErrors, null, 2)), + contentType: 'application/json', + }); + }); +}