diff --git a/extensions/json-language-features/server/package-lock.json b/extensions/json-language-features/server/package-lock.json index ce40e1b75ef62e..e8f28efa0c16e5 100644 --- a/extensions/json-language-features/server/package-lock.json +++ b/extensions/json-language-features/server/package-lock.json @@ -10,9 +10,8 @@ "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", - "jsonc-parser": "^4.0.0-next.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^6.0.0-next.2", + "vscode-json-languageservice": "^6.0.0-next.3", "vscode-languageserver": "^10.0.0-next.18", "vscode-uri": "^3.1.0" }, @@ -41,12 +40,6 @@ "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==" }, - "node_modules/jsonc-parser": { - "version": "4.0.0-next.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-4.0.0-next.1.tgz", - "integrity": "sha512-hHbZBY6wf/jvnF9bGTJ0VN3c73ch4VEHalHBPlKVnga5hnYOPNz8RDGS2lLivA8a0HAlfCJiJ9NEbbpKz6+zgg==", - "license": "MIT" - }, "node_modules/request-light": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.8.0.tgz", @@ -60,9 +53,9 @@ "license": "MIT" }, "node_modules/vscode-json-languageservice": { - "version": "6.0.0-next.2", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-6.0.0-next.2.tgz", - "integrity": "sha512-eq5p35un/ZPTI/6pQW3qYPsHSEmKkZVdBK9zMoB/9uM0KRqbTq2HMoCr8raei+/4pfqDoLqazbiscUy20eJ7MA==", + "version": "6.0.0-next.3", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-6.0.0-next.3.tgz", + "integrity": "sha512-bJ4DstTqrzjOZBKi2TsOD3UD8DwxWYL5yTgfrb6+VHuX1pi+FXMMyBfM3LrIrOjDagRbe4QLQz+YOSMg95i7Vw==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", @@ -72,6 +65,12 @@ "vscode-uri": "^3.1.0" } }, + "node_modules/vscode-json-languageservice/node_modules/jsonc-parser": { + "version": "4.0.0-next.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-4.0.0-next.1.tgz", + "integrity": "sha512-hHbZBY6wf/jvnF9bGTJ0VN3c73ch4VEHalHBPlKVnga5hnYOPNz8RDGS2lLivA8a0HAlfCJiJ9NEbbpKz6+zgg==", + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "9.0.0-next.12", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.12.tgz", diff --git a/extensions/json-language-features/server/package.json b/extensions/json-language-features/server/package.json index 743f445ba413a2..a9315943e2dec8 100644 --- a/extensions/json-language-features/server/package.json +++ b/extensions/json-language-features/server/package.json @@ -14,9 +14,8 @@ "main": "./out/node/jsonServerMain", "dependencies": { "@vscode/l10n": "^0.0.18", - "jsonc-parser": "^4.0.0-next.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^6.0.0-next.2", + "vscode-json-languageservice": "^6.0.0-next.3", "vscode-languageserver": "^10.0.0-next.18", "vscode-uri": "^3.1.0" }, diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 8c9ad8ebb07225..33bf50cd90bc45 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -1275,6 +1275,16 @@ export interface StickyScrollNode { readonly endIndex: number; readonly height: number; readonly position: number; + readonly sourceNodeEnd: number; + readonly sourceNodePartiallyVisible: boolean; + readonly hasExplicitHeight: boolean; +} + +export interface IStickyScrollNodeSourceRange { + readonly start: number; + readonly end: number; + readonly stickyNodeHeight?: number; + readonly estimated?: boolean; } function stickyScrollNodeStateEquals(node1: StickyScrollNode, node2: StickyScrollNode) { @@ -1285,7 +1295,10 @@ function stickyScrollNodeEquals(node1: StickyScrollNode { @@ -1499,28 +1512,43 @@ class StickyScrollController extends Disposable { return undefined; } - if (nextStickyNode === firstVisibleNodeUnderWidget) { - if (!this.nodeIsUncollapsedParent(firstVisibleNodeUnderWidget)) { - return undefined; - } + if (nextStickyNode === firstVisibleNodeUnderWidget && !this.nodeIsUncollapsedParent(firstVisibleNodeUnderWidget)) { + return undefined; + } - if (this.tree.options.stickyScrollShowOnlyWhenNodeFullyHidden) { - return undefined; - } + const sourceRange = this.getStickyScrollNodeSourceRange(nextStickyNode); + if (!sourceRange || !Number.isFinite(sourceRange.start) || !Number.isFinite(sourceRange.end) || sourceRange.end <= sourceRange.start) { + return undefined; + } + const sourceNodeTop = this.view.getElementTop(this.getNodeIndex(nextStickyNode)); + const stickyViewportBottom = this.view.scrollTop + stickyNodesHeight; + if (stickyViewportBottom <= sourceNodeTop + sourceRange.start) { + return undefined; + } + const height = this.getStickyScrollNodeHeight(sourceRange, stickyNodesHeight); + const sourceNodePartiallyVisible = stickyViewportBottom + height < sourceNodeTop + sourceRange.end; - if (this.nodeTopAlignsWithStickyNodesBottom(firstVisibleNodeUnderWidget, stickyNodesHeight)) { - return undefined; - } + return this.createStickyScrollNode(nextStickyNode, stickyNodesHeight, height, sourceRange.end, sourceNodePartiallyVisible, sourceRange.stickyNodeHeight !== undefined); + } + + private getStickyScrollNodeSourceRange(node: ITreeNode): IStickyScrollNodeSourceRange | undefined { + const defaultRange = { start: 0, end: this.getNodeHeight(node) }; + const sourceRangeProvider = this.tree.options.stickyScrollNodeSourceRangeProvider; + if (sourceRangeProvider) { + return sourceRangeProvider(node.element, defaultRange); } - return this.createStickyScrollNode(nextStickyNode, stickyNodesHeight); + return defaultRange; } - private nodeTopAlignsWithStickyNodesBottom(node: ITreeNode, stickyNodesHeight: number): boolean { - const nodeIndex = this.getNodeIndex(node); - const elementTop = this.view.getElementTop(nodeIndex); - const stickyPosition = stickyNodesHeight; - return this.view.scrollTop === elementTop - stickyPosition; + private getStickyScrollNodeHeight(sourceRange: IStickyScrollNodeSourceRange, currentStickyNodesHeight: number): number { + const height = this.clampNodeHeight(sourceRange.stickyNodeHeight ?? sourceRange.end - sourceRange.start); + if (!sourceRange.estimated) { + return height; + } + + const availableHeight = Math.max(1, this.view.renderHeight * this.maxWidgetViewRatio - currentStickyNodesHeight); + return Math.min(height, availableHeight); } private getNodeHeight(node: ITreeNode): number { @@ -1537,13 +1565,12 @@ class StickyScrollController extends Disposable { return max !== undefined ? Math.min(height, max) : height; } - private createStickyScrollNode(node: ITreeNode, currentStickyNodesHeight: number): StickyScrollNode { - const height = this.clampNodeHeight(this.getNodeHeight(node)); + private createStickyScrollNode(node: ITreeNode, currentStickyNodesHeight: number, height: number, sourceNodeEnd: number, sourceNodePartiallyVisible: boolean, hasExplicitHeight: boolean): StickyScrollNode { const { startIndex, endIndex } = this.getNodeRange(node); const position = this.calculateStickyNodePosition(endIndex, currentStickyNodesHeight, height); - return { node, position, height, startIndex, endIndex }; + return { node, position, height, startIndex, endIndex, sourceNodeEnd, sourceNodePartiallyVisible, hasExplicitHeight }; } private getAncestorUnderPrevious(node: ITreeNode, previousAncestor: ITreeNode | undefined = undefined): ITreeNode | undefined { @@ -1566,23 +1593,8 @@ class StickyScrollController extends Disposable { } private calculateStickyNodePosition(lastDescendantIndex: number, stickyRowPositionTop: number, stickyNodeHeight: number): number { - let lastChildRelativeTop = this.view.getRelativeTop(lastDescendantIndex); - - // If the last descendant is only partially visible at the top of the view, getRelativeTop() returns null - // In that case, utilize the next node's relative top to calculate the sticky node's position - if (lastChildRelativeTop === null && this.view.firstVisibleIndex === lastDescendantIndex && lastDescendantIndex + 1 < this.view.length) { - const nodeHeight = this.view.getElementHeight(lastDescendantIndex); - const nextNodeRelativeTop = this.view.getRelativeTop(lastDescendantIndex + 1); - lastChildRelativeTop = nextNodeRelativeTop ? nextNodeRelativeTop - nodeHeight / this.view.renderHeight : null; - } - - if (lastChildRelativeTop === null) { - return stickyRowPositionTop; - } - const lastChildHeight = this.view.getElementHeight(lastDescendantIndex); - const topOfLastChild = lastChildRelativeTop * this.view.renderHeight; - const bottomOfLastChild = topOfLastChild + lastChildHeight; + const bottomOfLastChild = this.view.getElementTop(lastDescendantIndex) + lastChildHeight - this.view.scrollTop; if (stickyRowPositionTop + stickyNodeHeight > bottomOfLastChild && stickyRowPositionTop <= bottomOfLastChild) { return bottomOfLastChild - stickyNodeHeight; @@ -1660,7 +1672,10 @@ class StickyScrollController extends Disposable { let widgetHeight = 0; for (let i = 0; i < ancestors.length && i < this.stickyScrollMaxItemCount; i++) { - widgetHeight += this.clampNodeHeight(this.getNodeHeight(ancestors[i])); + const sourceRange = this.getStickyScrollNodeSourceRange(ancestors[i]); + if (sourceRange) { + widgetHeight += this.getStickyScrollNodeHeight(sourceRange, widgetHeight); + } } return widgetHeight; } @@ -1692,6 +1707,15 @@ class StickyScrollController extends Disposable { } } + refresh(): void { + this.update(); + } + + rerender(): void { + this._widget.rerender(); + this.update(); + } + validateStickySettings(options: IAbstractTreeOptionsUpdate): { stickyScrollMaxItemCount: number } { let stickyScrollMaxItemCount = 7; if (typeof options.stickyScrollMaxItemCount === 'number') { @@ -1706,6 +1730,7 @@ class StickyScrollWidget implements IDisposable { private readonly _rootDomNode: HTMLElement; private _previousState: StickyScrollState | undefined; private _previousElements: HTMLElement[] = []; + private _previousElementHeights: number[] = []; private readonly _previousStateDisposables: DisposableStore = new DisposableStore(); get state(): StickyScrollState | undefined { return this._previousState; } @@ -1758,6 +1783,7 @@ class StickyScrollWidget implements IDisposable { // If state has not changed, do nothing if ((!wasVisible && !isVisible) || (wasVisible && isVisible && this._previousState!.equal(state))) { + this.updateSourceNodeVisibility(state); return; } @@ -1767,8 +1793,10 @@ class StickyScrollWidget implements IDisposable { } if (!isVisible) { + this.updateSourceNodeVisibility(undefined); this._previousState = undefined; this._previousElements = []; + this._previousElementHeights = []; this._previousStateDisposables.clear(); return; } @@ -1786,9 +1814,28 @@ class StickyScrollWidget implements IDisposable { this._previousState = state; + this.updateSourceNodeVisibility(state); this.updateRootHeight(state); } + private updateSourceNodeVisibility(state: StickyScrollState | undefined): void { + let sourceNodePartiallyVisible = false; + for (let i = 0; state && i < state.count; i++) { + const stickyNode = state.stickyNodes[i]; + const stickyElement = this._previousElements[i]; + let nodePartiallyVisible = stickyNode.sourceNodePartiallyVisible; + if (stickyElement) { + const sourceNodeBottom = this.view.getElementTop(stickyNode.startIndex) + stickyNode.sourceNodeEnd; + const stickyElementHeight = this.getRenderedNodeHeight(stickyNode, i); + const stickyNodeBottom = this.view.scrollTop + stickyNode.position + stickyElementHeight; + nodePartiallyVisible = stickyNodeBottom < sourceNodeBottom; + } + stickyElement?.classList.toggle('source-node-partially-visible', nodePartiallyVisible); + sourceNodePartiallyVisible ||= nodePartiallyVisible; + } + this._rootDomNode.classList.toggle('source-node-partially-visible', sourceNodePartiallyVisible); + } + private renderState(state: StickyScrollState): void { this._previousStateDisposables.clear(); @@ -1807,13 +1854,13 @@ class StickyScrollWidget implements IDisposable { this._previousElements = elements; - // Probe dynamic heights after rendering into DOM - this.probeDynamicHeights(state, elements); + this._previousElementHeights = this.probeDynamicHeights(state, elements); } rerender(): void { if (this._previousState) { this.renderState(this._previousState); + this.updateSourceNodeVisibility(this._previousState); this.updateRootHeight(this._previousState); } } @@ -1824,17 +1871,25 @@ class StickyScrollWidget implements IDisposable { private getRootHeight(state: StickyScrollState): number { const lastStickyNode = state.stickyNodes[state.count - 1]; - const lastStickyElement = this._previousElements[state.count - 1]; - const lastStickyElementHeight = lastStickyElement?.offsetHeight ?? lastStickyNode.height; - return lastStickyNode.position + lastStickyElementHeight; + const lastStickyElementHeight = this.getRenderedNodeHeight(lastStickyNode, state.count - 1); + return Math.max(0, lastStickyNode.position + lastStickyElementHeight); } - private probeDynamicHeights(state: StickyScrollState, elements: HTMLElement[]): void { + private getRenderedNodeHeight(stickyNode: StickyScrollNode, index: number): number { + return Math.min(this._previousElementHeights[index] ?? stickyNode.height, stickyNode.height); + } + + private probeDynamicHeights(state: StickyScrollState, elements: HTMLElement[]): number[] { const heightChanges: { index: number; height: number }[] = []; + const elementHeights = state.stickyNodes.map(node => node.height); for (let i = 0; i < state.count; i++) { const stickyNode = state.stickyNodes[i]; if (!this.treeDelegate.hasDynamicHeight || !this.treeDelegate.hasDynamicHeight(stickyNode.node)) { + const measuredHeight = elements[i].offsetHeight; + if (measuredHeight > 0) { + elementHeights[i] = measuredHeight; + } continue; } @@ -1850,18 +1905,19 @@ class StickyScrollWidget implements IDisposable { } const maxNodeHeight = this.tree.options.stickyScrollMaxNodeHeight; const clampedMeasuredHeight = maxNodeHeight !== undefined ? Math.min(measuredHeight, maxNodeHeight) : measuredHeight; + const renderedHeight = stickyNode.hasExplicitHeight ? stickyNode.height : clampedMeasuredHeight; + elementHeights[i] = renderedHeight; // Always update the sticky element's visual height to match the measured content if (this.tree.options.setRowHeight !== false) { - element.style.height = `${clampedMeasuredHeight}px`; + element.style.height = `${renderedHeight}px`; } if (this.tree.options.setRowLineHeight !== false) { - element.style.lineHeight = `${clampedMeasuredHeight}px`; + element.style.lineHeight = `${renderedHeight}px`; } - // Only propagate height increases to the real row — never shrink it, - // since sticky elements may have CSS truncation (e.g. line-clamp). - if (clampedMeasuredHeight > stickyNode.height) { + // A sticky row may represent only part of its source, so never shrink the source row. + if (!stickyNode.hasExplicitHeight && clampedMeasuredHeight > this.view.getElementHeight(stickyNode.startIndex)) { heightChanges.push({ index: stickyNode.startIndex, height: clampedMeasuredHeight }); } } @@ -1869,6 +1925,8 @@ class StickyScrollWidget implements IDisposable { if (heightChanges.length > 0) { this._onDidChangeHeight.fire(heightChanges); } + + return elementHeights; } private createElement(stickyNode: StickyScrollNode, stickyIndex: number, stickyNodesTotal: number): { element: HTMLElement; disposable: IDisposable } { @@ -1894,6 +1952,7 @@ class StickyScrollWidget implements IDisposable { stickyElement.classList.add('monaco-tree-sticky-row'); stickyElement.classList.add('monaco-list-row'); + stickyElement.classList.toggle('source-node-partially-visible', stickyNode.sourceNodePartiallyVisible); stickyElement.setAttribute('data-index', `${nodeIndex}`); stickyElement.setAttribute('data-parity', nodeIndex % 2 === 0 ? 'even' : 'odd'); @@ -2311,7 +2370,6 @@ export interface IAbstractTreeOptionsUpdate extends ITreeRendererOptions { readonly enableStickyScroll?: boolean; readonly stickyScrollMaxItemCount?: number; readonly stickyScrollMaxNodeHeight?: number; - readonly stickyScrollShowOnlyWhenNodeFullyHidden?: boolean; readonly paddingTop?: number; } @@ -2327,6 +2385,7 @@ export interface IAbstractTreeOptions extends IAbstractTr readonly findWidgetContainer?: HTMLElement; readonly defaultFindVisibility?: TreeVisibility | ((e: T) => TreeVisibility); readonly stickyScrollDelegate?: IStickyScrollDelegate; + readonly stickyScrollNodeSourceRangeProvider?: (element: T, defaultRange: IStickyScrollNodeSourceRange) => IStickyScrollNodeSourceRange | undefined; readonly disableExpandOnSpacebar?: boolean; // defaults to false } @@ -2909,6 +2968,14 @@ export abstract class AbstractTree implements IDisposable return this.view.renderHeight; } + refreshStickyScroll(): void { + this.stickyScrollController?.refresh(); + } + + rerenderStickyScroll(): void { + this.stickyScrollController?.rerender(); + } + get firstVisibleElement(): T | undefined { let index = this.view.firstVisibleIndex; diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index d3c4c24efcc887..904321a93f76a5 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -475,6 +475,7 @@ function asObjectTreeOptions(options?: IAsyncDataTreeOpt return options.keyboardNavigationLabelProvider!.getKeyboardNavigationLabel(e.element as T); } }, + stickyScrollNodeSourceRangeProvider: options.stickyScrollNodeSourceRangeProvider && ((e, defaultRange) => options.stickyScrollNodeSourceRangeProvider!(e.element as T, defaultRange)), sorter: undefined, expandOnlyOnTwistieClick: typeof options.expandOnlyOnTwistieClick === 'undefined' ? undefined : ( typeof options.expandOnlyOnTwistieClick !== 'function' ? options.expandOnlyOnTwistieClick : ( diff --git a/src/vs/base/browser/ui/tree/objectTree.ts b/src/vs/base/browser/ui/tree/objectTree.ts index ef196a252848fe..846b33eb441f34 100644 --- a/src/vs/base/browser/ui/tree/objectTree.ts +++ b/src/vs/base/browser/ui/tree/objectTree.ts @@ -234,6 +234,9 @@ class CompressibleStickyScrollDelegate implements IStickyScrollD endIndex: lastStickyNode.endIndex, position: stickyNodes[0].position, height: stickyNodes[0].height, + sourceNodeEnd: stickyNodes[0].sourceNodeEnd, + sourceNodePartiallyVisible: stickyNodes.some(node => node.sourceNodePartiallyVisible), + hasExplicitHeight: stickyNodes[0].hasExplicitHeight, }; this.compressedStickyNodes.set(stickyTreeNode, compressedNode); diff --git a/src/vs/base/test/browser/ui/tree/objectTree.test.ts b/src/vs/base/test/browser/ui/tree/objectTree.test.ts index 619a2469fdc2ec..94fa5f2fe921d3 100644 --- a/src/vs/base/test/browser/ui/tree/objectTree.test.ts +++ b/src/vs/base/test/browser/ui/tree/objectTree.test.ts @@ -10,6 +10,7 @@ import { ICompressedTreeNode } from '../../../../browser/ui/tree/compressedObjec import { CompressibleObjectTree, ICompressibleTreeRenderer, ObjectTree } from '../../../../browser/ui/tree/objectTree.js'; import { ObjectTreeModel } from '../../../../browser/ui/tree/objectTreeModel.js'; import { ITreeNode, ITreeRenderer } from '../../../../browser/ui/tree/tree.js'; +import { mainWindow } from '../../../../browser/window.js'; import { Emitter, Event } from '../../../../common/event.js'; import { SetMap } from '../../../../common/map.js'; import { runWithFakedTimers } from '../../../common/timeTravelScheduler.js'; @@ -280,6 +281,138 @@ suite('ObjectTree', function () { } }); + test('shows the default sticky node after its source row starts scrolling out', function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '100px'; + + const tree = new ObjectTree('test', container, new Delegate(), [new Renderer()], { + enableStickyScroll: true, + stickyScrollMaxItemCount: 1, + }); + try { + tree.layout(100); + tree.setChildren(null, [{ + element: 0, + children: [ + { element: 1 }, + { element: 2 }, + { element: 3 }, + { element: 4 }, + { element: 5 }, + { element: 6 }, + ] + }]); + + const stickyText = () => container.querySelector('.monaco-tree-sticky-row')?.textContent; + const states = [stickyText()]; + tree.scrollTop = 1; + states.push(stickyText()); + + assert.deepStrictEqual(states, [undefined, '0']); + } finally { + tree.dispose(); + } + }); + + test('evaluates a custom sticky source range once per scroll update', function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '100px'; + const providerCalls: { element: number; defaultRange: { start: number; end: number } }[] = []; + + const tree = new ObjectTree('test', container, new Delegate(), [new Renderer()], { + enableStickyScroll: true, + stickyScrollMaxItemCount: 1, + stickyScrollNodeSourceRangeProvider: (element, defaultRange) => { + providerCalls.push({ element, defaultRange }); + return { start: 5, end: 15 }; + }, + }); + try { + tree.layout(100); + tree.setChildren(null, [{ + element: 0, + children: [ + { element: 1 }, + { element: 2 }, + { element: 3 }, + { element: 4 }, + { element: 5 }, + { element: 6 }, + ] + }]); + + tree.scrollTop = 5; + const stickyAtRangeStart = container.querySelector('.monaco-tree-sticky-row')?.textContent; + tree.scrollTop = 6; + const stickyAfterRangeStart = container.querySelector('.monaco-tree-sticky-row')?.textContent; + + assert.deepStrictEqual({ + stickyAtRangeStart, + stickyAfterRangeStart, + providerCalls, + }, { + stickyAtRangeStart: undefined, + stickyAfterRangeStart: '0', + providerCalls: [ + { element: 0, defaultRange: { start: 0, end: 20 } }, + { element: 0, defaultRange: { start: 0, end: 20 } }, + ], + }); + } finally { + tree.dispose(); + } + }); + + test('shrinks a sticky node to its final pixel before the next root', async function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '100px'; + + const tree = new ObjectTree('test', container, new Delegate(), [new Renderer()], { + enableStickyScroll: true, + stickyScrollMaxItemCount: 1, + }); + try { + tree.layout(100); + tree.setChildren(null, [ + { element: 100, children: [{ element: 1 }, { element: 10 }] }, + { element: 2 }, + { element: 3 }, + { element: 4 }, + { element: 5 }, + { element: 6 }, + ]); + + tree.scrollTop = 1; + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + tree.scrollTop = 59; + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + const stickyBeforeBoundary = container.querySelector('.monaco-tree-sticky-row'); + const positionBeforeBoundary = stickyBeforeBoundary?.style.top; + const visibleHeightBeforeBoundary = stickyBeforeBoundary ? Number.parseFloat(stickyBeforeBoundary.style.top) + Number.parseFloat(stickyBeforeBoundary.style.height) : undefined; + tree.scrollTop = 60; + const positionAtBoundary = container.querySelector('.monaco-tree-sticky-row')?.style.top; + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + const stickyAfterBoundary = container.querySelector('.monaco-tree-sticky-row'); + + assert.deepStrictEqual({ + positionBeforeBoundary, + visibleHeightBeforeBoundary, + positionAtBoundary, + stickyAfterBoundary: !!stickyAfterBoundary, + }, { + positionBeforeBoundary: '-19px', + visibleHeightBeforeBoundary: 1, + positionAtBoundary: undefined, + stickyAfterBoundary: false, + }); + } finally { + tree.dispose(); + } + }); + test('disposing an older render preserves the current node mapping', function () { const onDidChangeTwistieState = new Emitter(); const renderer: ITreeRenderer = { diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 17eabfcd70971a..6d121755fcffcb 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -157,6 +157,12 @@ export interface UsageInfoMeta { * what a completed turn consumed in aggregate. */ turnTokenTotals?: readonly ITurnTokenTotal[]; + /** Per-model token totals for this turn only, excluding descendant sub-agents (sum a tree without double-counting). */ + directTurnTokenTotals?: readonly ITurnTokenTotal[]; + /** Copilot usage for this turn only. The root's {@link copilotUsage} stays inclusive of descendants. */ + directCopilotUsage?: { + readonly totalNanoAiu?: number; + }; [key: string]: unknown; } @@ -284,6 +290,17 @@ export function readUsageInfoMeta(usage: UsageInfo | undefined): UsageInfoMeta { if (turnTokenTotals) { result.turnTokenTotals = turnTokenTotals; } + const directTurnTokenTotals = readTurnTokenTotals(meta['directTurnTokenTotals']); + if (directTurnTokenTotals) { + result.directTurnTokenTotals = directTurnTokenTotals; + } + const directCopilotUsage = meta['directCopilotUsage']; + if (directCopilotUsage && typeof directCopilotUsage === 'object' && !Array.isArray(directCopilotUsage)) { + const totalNanoAiu = (directCopilotUsage as Record)['totalNanoAiu']; + if (typeof totalNanoAiu === 'number') { + result.directCopilotUsage = { totalNanoAiu }; + } + } return result; } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 0106d75c10968e..afbfc788b6c8e2 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -175,6 +175,8 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemet chatSessionId: string; isSubagentSession: boolean; turnId: string; + parentTurnId: string | undefined; + parentToolCallId: string | undefined; timeToFirstProgress: number | undefined; totalTime: number; result: AgentHostTurnResult; @@ -188,6 +190,10 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemet isMultiRoot: boolean; folderCount: number; billedNanoAiu: number | undefined; + directPromptTokenCount: number | undefined; + directPromptCacheTokenCount: number | undefined; + directCompletionTokenCount: number | undefined; + directBilledNanoAiu: number | undefined; modelCallCount: number; } @@ -197,6 +203,8 @@ export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassific chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the agent host session.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the turn belongs to a subagent session.' }; turnId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the turn within the agent host session.' }; + parentTurnId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The immediate parent turn identifier for a subagent turn.' }; + parentToolCallId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the tool call that spawned the subagent owning this turn; stable across resumed turns of the same subagent.' }; timeToFirstProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from turn start to the first visible progress (text delta, response part, tool call start, or reasoning).' }; totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from turn start to turn completion.' }; result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the turn completed successfully, with an error, or was cancelled.' }; @@ -210,7 +218,11 @@ export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassific isMultiRoot: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the session spans more than one working directory.' }; folderCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of effective working directories for the session at turn completion.' }; billedNanoAiu: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The AI credit usage billed for the turn in nano-AIU, when reported by the provider.' }; - modelCallCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of completed upstream model responses attributed directly to the turn.' }; + directPromptTokenCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Input tokens used directly by this turn, excluding descendant sub-agent calls.' }; + directPromptCacheTokenCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Input tokens read from cache directly by this turn, excluding descendant sub-agent calls.' }; + directCompletionTokenCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Output tokens generated directly by this turn, excluding descendant sub-agent calls.' }; + directBilledNanoAiu: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'AI credit usage billed directly to this turn in nano-AIU, excluding descendant sub-agent calls.' }; + modelCallCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of completed upstream model responses attributed directly to this turn, excluding descendant sub-agent calls.' }; owner: 'roblourens'; comment: 'Tracks agent host turn completion, including performance, configuration context, completed model responses, and billed AI credit usage when reported by the provider.'; }; @@ -261,6 +273,8 @@ export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedR provider: string; session: string; turnId: string; + parentTurnId: string | undefined; + parentToolCallId: string | undefined; timeToFirstProgress: number | undefined; totalTime: number; result: AgentHostTurnResult; @@ -273,6 +287,10 @@ export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedR isMultiRoot: boolean; folderCount: number; billedNanoAiu: number | undefined; + directPromptTokenCount: number | undefined; + directPromptCacheTokenCount: number | undefined; + directCompletionTokenCount: number | undefined; + directBilledNanoAiu: number | undefined; modelCallCount: number; } @@ -1151,6 +1169,8 @@ export class AgentHostTelemetryReporter { chatSessionId, isSubagentSession: isSubagent, turnId: report.turnId, + parentTurnId: report.parentTurnId, + parentToolCallId: report.parentToolCallId, timeToFirstProgress: report.timeToFirstProgress, totalTime: report.totalTime, result: report.result, @@ -1164,6 +1184,10 @@ export class AgentHostTelemetryReporter { isMultiRoot: report.isMultiRoot, folderCount: report.folderCount, billedNanoAiu: report.billedNanoAiu, + directPromptTokenCount: report.directPromptTokenCount, + directPromptCacheTokenCount: report.directPromptCacheTokenCount, + directCompletionTokenCount: report.directCompletionTokenCount, + directBilledNanoAiu: report.directBilledNanoAiu, modelCallCount: report.modelCallCount, }); if (report.failure) { diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 66febde0d5fbf1..b1dafb8a25522d 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -17,7 +17,7 @@ import { IAgentHostClientConnectionService } from './agentHostClientConnectionSe import { ILogService } from '../../log/common/log.js'; import { canRefineContributor, toolSourceKindFromContributor } from './agentHostToolCallTracker.js'; import { SessionInputRequestKind } from '../common/state/protocol/state.js'; -import type { ToolCallContributor } from '../common/state/sessionState.js'; +import type { ITurnTokenTotal, ToolCallContributor } from '../common/state/sessionState.js'; import type { AgentHostInitiatorClientConnectionState, AgentHostModelTelemetryKind, AgentHostProviderDiagnosticState, AgentHostTelemetryReporter, AgentHostTurnFailureStage, AgentHostTurnHangReason, AgentHostTurnResult, IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; /** @@ -59,6 +59,8 @@ interface ITurnTiming { readonly agent: IAgent; readonly session: string; readonly turnId: string; + readonly parentTurnId: string | undefined; + readonly parentToolCallId: string | undefined; model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; readonly modelSelectionKind: 'default' | 'auto' | 'explicit'; @@ -96,6 +98,10 @@ interface ITurnTiming { interface ITurnUsage { billedNanoAiu?: number; + directPromptTokenCount?: number; + directPromptCacheTokenCount?: number; + directCompletionTokenCount?: number; + directBilledNanoAiu?: number; } /** @@ -153,13 +159,15 @@ export class AgentHostTurnTracker extends Disposable { })); } - turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string): void { + turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string): void { const key = this._key(session, turnId); this._turnTimings.set(key, { stopWatch: StopWatch.create(false), agent, session, turnId, + parentTurnId, + parentToolCallId, model, modelTelemetryKind, modelSelectionKind, @@ -326,6 +334,21 @@ export class AgentHostTurnTracker extends Disposable { } } + updateDirectUsage(session: string, turnId: string, tokenTotals: readonly ITurnTokenTotal[] | undefined, billedNanoAiu: number | undefined): void { + const usage = this._turnUsages.get(this._key(session, turnId)); + if (!usage) { + return; + } + if (tokenTotals) { + usage.directPromptTokenCount = sumTokenCounts(tokenTotals, total => total.inputTokens); + usage.directPromptCacheTokenCount = sumTokenCounts(tokenTotals, total => total.cachedTokens); + usage.directCompletionTokenCount = sumTokenCounts(tokenTotals, total => total.outputTokens); + } + if (typeof billedNanoAiu === 'number' && Number.isFinite(billedNanoAiu) && billedNanoAiu >= 0) { + usage.directBilledNanoAiu = billedNanoAiu; + } + } + modelCallCompleted(session: string, turnId: string, modelCallId: string): void { this._turnTimings.get(this._key(session, turnId))?.completedModelCallIds.add(modelCallId); } @@ -357,6 +380,8 @@ export class AgentHostTurnTracker extends Disposable { provider: timing.agent.id, session: timing.session, turnId, + parentTurnId: timing.parentTurnId, + parentToolCallId: timing.parentToolCallId, timeToFirstProgress: timing.firstProgressMs, totalTime: timing.stopWatch.elapsed(), result, @@ -369,6 +394,10 @@ export class AgentHostTurnTracker extends Disposable { isMultiRoot: workspace?.isMultiRoot ?? false, folderCount: workspace?.folderCount ?? 0, billedNanoAiu: usage?.billedNanoAiu, + directPromptTokenCount: usage?.directPromptTokenCount, + directPromptCacheTokenCount: usage?.directPromptCacheTokenCount, + directCompletionTokenCount: usage?.directCompletionTokenCount, + directBilledNanoAiu: usage?.directBilledNanoAiu, modelCallCount: timing.completedModelCallIds.size, }); @@ -568,3 +597,10 @@ export class AgentHostTurnTracker extends Disposable { return `${session}\0${turnId}`; } } + +function sumTokenCounts(totals: readonly ITurnTokenTotal[], getCount: (total: ITurnTokenTotal) => number): number { + return totals.reduce((sum, total) => { + const count = getCount(total); + return sum + (Number.isFinite(count) && count >= 0 ? count : 0); + }, 0); +} diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 4bba47cd263c58..c1cb1902d7ecaa 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -152,12 +152,21 @@ interface IPendingSubagentSignal { interface ISubagentSessionRef { readonly parentChatUri: ProtocolURI; + readonly immediateParentChatUri: ProtocolURI | undefined; readonly toolCallId: string; readonly sessionUri: ProtocolURI; readonly chatUri: ProtocolURI; readonly turnStopWatch: StopWatch; } +interface ISubagentParentTurnTelemetryContext { + readonly parentTurnId: string | undefined; + readonly parentClientContext: IAgentHostClientTelemetryContext | undefined; + /** Hierarchy edge; set only when the immediate parent chat has an active turn, else omitted. */ + readonly correlatedParentTurnId: string | undefined; + readonly initiatorClientId: string | undefined; +} + interface ICustomizationEnablementCandidate { readonly customization: PluginCustomization | McpServerCustomization; readonly owningPluginUri?: string; @@ -994,9 +1003,16 @@ export class AgentSideEffects extends Disposable { } } if (action.type === ActionType.ChatUsage) { + const usageMeta = readUsageInfoMeta(action.usage); + this._turnTracker.updateDirectUsage( + sessionKey, + action.turnId, + usageMeta.directTurnTokenTotals, + usageMeta.directCopilotUsage?.totalNanoAiu, + ); // Subagent charges are already folded into the parent turn's aggregate. if (!isSubagentChatUri(sessionKey)) { - this._turnTracker.updateBilledNanoAiu(sessionKey, action.turnId, readUsageInfoMeta(action.usage).copilotUsage?.totalNanoAiu); + this._turnTracker.updateBilledNanoAiu(sessionKey, action.turnId, usageMeta.copilotUsage?.totalNanoAiu); } if (action.usage.model && agent) { const modelContext = this._getModelTelemetryContext(agent, action.usage.model); @@ -1256,26 +1272,24 @@ export class AgentSideEffects extends Disposable { ): void { const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI); const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId); + const immediateParentChatUri = spawningToolParentId + ? this._subagentChats.get(chatURI, spawningToolParentId)?.chatUri + : chatURI; + const contentChatUri = immediateParentChatUri ?? chatURI; const existing = this._subagentChats.get(chatURI, toolCallId); if (existing) { - this._resumeSubagentSession(chatURI, toolCallId, taskPrompt ? { text: taskPrompt, origin: { kind: MessageKind.User } } : undefined); + this._resumeSubagentSession(chatURI, toolCallId, taskPrompt ? { text: taskPrompt, origin: { kind: MessageKind.User } } : undefined, immediateParentChatUri); return; } this._logService.info(`[AgentSideEffects] Starting subagent turn: ${subagentChatUri} (parent=${chatURI}, toolCallId=${toolCallId})`); - // The spawning tool call lives in the immediate parent chat (top-level, or the parent subagent chat when nested). - const contentChatUri = spawningToolParentId - ? this._subagentChats.get(chatURI, spawningToolParentId)?.chatUri ?? chatURI - : chatURI; - // Seed the subagent's opening request with the delegated task prompt, // supplied by the provider on the `subagent_started` signal. const turnId = generateUuid(); const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); - const parentClientContext = parentTurnId ? this._turnTracker.getClientTelemetryContext(contentChatUri, parentTurnId) : undefined; - const parentClientId = parentTurnId ? this._turnTracker.getInitiatorClientId(contentChatUri, parentTurnId) : undefined; + const { parentClientContext, correlatedParentTurnId, initiatorClientId } = this._getSubagentParentTurnTelemetryContext(immediateParentChatUri, contentChatUri); this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, @@ -1284,11 +1298,11 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(parentSessionUri); if (agent) { - this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, parentClientId); + this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } - this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false) }, chatURI, toolCallId); + this._subagentChats.set({ parentChatUri: chatURI, immediateParentChatUri, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false) }, chatURI, toolCallId); // Dispatch the discovery content on the spawning tool call's own chat; the top-level chat is a no-op when nested. if (parentTurnId) { @@ -1336,7 +1350,7 @@ export class AgentSideEffects extends Disposable { return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0; } - private _resumeSubagentSession(parentChatURI: ProtocolURI, toolCallId: string, message: Message | undefined): void { + private _resumeSubagentSession(parentChatURI: ProtocolURI, toolCallId: string, message: Message | undefined, immediateParentChatURI?: ProtocolURI): void { const subagent = this._subagentChats.get(parentChatURI, toolCallId); if (!subagent) { this._logService.error(`[AgentSideEffects] Cannot resume unknown subagent ${parentChatURI}/${toolCallId}`); @@ -1347,9 +1361,9 @@ export class AgentSideEffects extends Disposable { } const turnId = generateUuid(); - const parentTurnId = this._stateManager.getActiveTurnId(parentChatURI); - const parentClientContext = parentTurnId ? this._turnTracker.getClientTelemetryContext(parentChatURI, parentTurnId) : undefined; - const parentClientId = parentTurnId ? this._turnTracker.getInitiatorClientId(parentChatURI, parentTurnId) : undefined; + const correlatedParentChatUri = immediateParentChatURI ?? subagent.immediateParentChatUri; + const parentChatUri = correlatedParentChatUri ?? parentChatURI; + const { parentClientContext, correlatedParentTurnId, initiatorClientId } = this._getSubagentParentTurnTelemetryContext(correlatedParentChatUri, parentChatUri); this._logService.info(`[AgentSideEffects] Resuming subagent turn: ${subagent.chatUri} (parent=${parentChatURI}, toolCallId=${toolCallId})`); this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnStarted, @@ -1359,10 +1373,21 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(subagent.sessionUri); if (agent) { - this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, parentClientId); + this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } - this._subagentChats.set({ ...subagent, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); + this._subagentChats.set({ ...subagent, immediateParentChatUri: correlatedParentChatUri, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); + } + + private _getSubagentParentTurnTelemetryContext(immediateParentChatUri: ProtocolURI | undefined, fallbackParentChatUri: ProtocolURI): ISubagentParentTurnTelemetryContext { + const parentChatUri = immediateParentChatUri ?? fallbackParentChatUri; + const parentTurnId = this._stateManager.getActiveTurnId(parentChatUri); + return { + parentTurnId, + parentClientContext: parentTurnId ? this._turnTracker.getClientTelemetryContext(parentChatUri, parentTurnId) : undefined, + correlatedParentTurnId: immediateParentChatUri ? parentTurnId : undefined, + initiatorClientId: parentTurnId ? this._turnTracker.getInitiatorClientId(parentChatUri, parentTurnId) : undefined, + }; } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 3e82d8a575dff8..4952288fdd6dce 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -504,6 +504,37 @@ interface IMcpLifecycleLogInfo { readonly pluginVersion?: string; } +class DirectUsageAccumulator { + private readonly _tokenTotalsByModel = new Map>(); + private _copilotNanoAiu: number | undefined; + + add(model: string | undefined, tokens: UsageContext, copilotNanoAiu: number | undefined): void { + if (model) { + let total = this._tokenTotalsByModel.get(model); + if (!total) { + total = { model, inputTokens: 0, cachedTokens: 0, outputTokens: 0 }; + this._tokenTotalsByModel.set(model, total); + } + total.inputTokens += toTokenCount(tokens.inputTokens); + total.cachedTokens += toTokenCount(tokens.cacheReadTokens); + total.outputTokens += toTokenCount(tokens.outputTokens); + } + if (typeof copilotNanoAiu === 'number') { + this._copilotNanoAiu = (this._copilotNanoAiu ?? 0) + copilotNanoAiu; + } + } + + get tokenTotals(): readonly ITurnTokenTotal[] | undefined { + return this._tokenTotalsByModel.size > 0 + ? [...this._tokenTotalsByModel.values()].map(total => ({ ...total })) + : undefined; + } + + get copilotNanoAiu(): number | undefined { + return this._copilotNanoAiu; + } +} + class CopilotTurn extends Disposable { private _state: CopilotTurnState = 'pending'; @@ -523,13 +554,7 @@ class CopilotTurn extends Disposable { */ copilotNanoAiu = 0; - /** - * Per-subagent component cost, in nano-AIU, keyed by `parentToolCallId`. - * The SDK's session metrics are session-wide and carry no per-agent - * breakdown, so a subagent's own running total is still accumulated from - * its usage events in order to report it on the subagent's child session. - */ - readonly subagentNanoAiuByToolCallId = new Map(); + readonly directUsage = new DirectUsageAccumulator(); /** * Whole-turn token consumption keyed by model id. Every model call in the @@ -546,13 +571,17 @@ class CopilotTurn extends Disposable { * usage-reporting path this session has carries one. */ addTokenTotals(model: string | undefined, tokens: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number }): void { + this._addTokenTotals(this._tokenTotalsByModel, model, tokens); + } + + private _addTokenTotals(totals: Map>, model: string | undefined, tokens: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number }): void { if (!model) { return; } - let total = this._tokenTotalsByModel.get(model); + let total = totals.get(model); if (!total) { total = { model, inputTokens: 0, cachedTokens: 0, outputTokens: 0 }; - this._tokenTotalsByModel.set(model, total); + totals.set(model, total); } total.inputTokens += toTokenCount(tokens.inputTokens); total.cachedTokens += toTokenCount(tokens.cacheReadTokens); @@ -566,8 +595,12 @@ class CopilotTurn extends Disposable { * change retroactively underneath its consumers. */ get tokenTotals(): readonly ITurnTokenTotal[] | undefined { - return this._tokenTotalsByModel.size > 0 - ? [...this._tokenTotalsByModel.values()].map(total => ({ ...total })) + return this._cloneTokenTotals(this._tokenTotalsByModel); + } + + private _cloneTokenTotals(totals: ReadonlyMap | undefined): readonly ITurnTokenTotal[] | undefined { + return totals?.size + ? [...totals.values()].map(total => ({ ...total })) : undefined; } @@ -707,6 +740,9 @@ export class CopilotAgentSession extends Disposable { * the same id, so mappings live until session teardown. */ private readonly _parentToolCallIdsByAgentId = new Map(); + private readonly _rootTurnIdBySubagentToolCallId = new Map(); + private readonly _subagentDirectUsageByToolCallId = new Map(); + private readonly _lastSubagentUsageByToolCallId = new Map(); private readonly _activeSubagentAgentIds = new Set(); private readonly _unroutableSubagentToolCallIds = new Set(); private readonly _autoApprovals = new Map(); @@ -1218,6 +1254,9 @@ export class CopilotAgentSession extends Disposable { if (!parentToolCallId) { return; } + if (this._currentTurn.value) { + this._rootTurnIdBySubagentToolCallId.set(parentToolCallId, this._currentTurn.value.id); + } this._activeSubagentAgentIds.add(e.agentId); this._onDidSessionProgress.fire({ kind: 'subagent_resumed', @@ -1244,6 +1283,29 @@ export class CopilotAgentSession extends Disposable { chat: this._chatChannelUri, toolCallId: parentToolCallId, }); + this._rootTurnIdBySubagentToolCallId.delete(parentToolCallId); + this._subagentDirectUsageByToolCallId.delete(parentToolCallId); + this._lastSubagentUsageByToolCallId.delete(parentToolCallId); + } + + private _directUsageFor(parentToolCallId: string | undefined, create: boolean): DirectUsageAccumulator | undefined { + if (!parentToolCallId) { + return this._currentTurn.value?.directUsage; + } + let usage = this._subagentDirectUsageByToolCallId.get(parentToolCallId); + if (!usage && create) { + usage = new DirectUsageAccumulator(); + this._subagentDirectUsageByToolCallId.set(parentToolCallId, usage); + } + return usage; + } + + private _owningRootTurn(parentToolCallId: string | undefined): CopilotTurn | undefined { + const turn = this._currentTurn.value; + if (!turn || (parentToolCallId && this._rootTurnIdBySubagentToolCallId.get(parentToolCallId) !== turn.id)) { + return undefined; + } + return turn; } private _shouldDropUnmappedSubagentEvent(e: { readonly agentId?: string }, eventName: string): boolean { @@ -2285,9 +2347,13 @@ export class CopilotAgentSession extends Disposable { // This emit replaces the turn's usage in the reducer, so carry the // whole-turn token totals accumulated so far too. const turnTokenTotals = this._currentTurn.value?.tokenTotals; + const directTurnTokenTotals = this._currentTurn.value?.directUsage.tokenTotals; + const directNanoAiu = this._currentTurn.value?.directUsage.copilotNanoAiu; const meta: UsageInfoMeta = { ...(copilotUsage ? { copilotUsage } : {}), ...(turnTokenTotals ? { turnTokenTotals } : {}), + ...(directTurnTokenTotals ? { directTurnTokenTotals } : {}), + ...(directNanoAiu !== undefined ? { directCopilotUsage: { totalNanoAiu: directNanoAiu } } : {}), }; this._emitAction({ type: ActionType.ChatUsage, @@ -4652,6 +4718,9 @@ export class CopilotAgentSession extends Disposable { this._parentToolCallIdsByAgentId.set(e.agentId, e.data.toolCallId); this._activeSubagentAgentIds.add(e.agentId); } + if (this._currentTurn.value) { + this._rootTurnIdBySubagentToolCallId.set(e.data.toolCallId, this._currentTurn.value.id); + } this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); const tracked = this._activeToolCalls.get(e.data.toolCallId); this._onDidSessionProgress.fire({ @@ -4753,8 +4822,18 @@ export class CopilotAgentSession extends Disposable { // needs only the subagent's own running component total emitted to its // child session (via `parentToolCallId`) for the subagent tool to show // its own cost. - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - if (!parentToolCallId && !e.agentId && !e.data.parentToolCallId) { + const mappedParentToolCallId = this._parentToolCallIdForSubagentEvent(e); + const parentToolCallId = mappedParentToolCallId ?? e.data.parentToolCallId; + const isUnmappedSubagent = !!e.agentId && !parentToolCallId; + // Never re-own an already-mapped child; that would fold an old child into a new root. + if (!mappedParentToolCallId && e.data.parentToolCallId && this._currentTurn.value + && !this._rootTurnIdBySubagentToolCallId.has(e.data.parentToolCallId)) { + this._rootTurnIdBySubagentToolCallId.set(e.data.parentToolCallId, this._currentTurn.value.id); + } + if (isUnmappedSubagent) { + this._logService.warn(`[Copilot:${sessionId}] Unable to attribute direct assistant.usage for unknown subagent agentId=${e.agentId}; retaining inclusive root usage`); + } + if (!parentToolCallId && !e.agentId) { this._promptCacheRefreshGeneration++; if (e.data.model && e.data.cacheExpiresAt) { this._setPromptCacheState({ modelId: e.data.model, cacheExpiresAt: e.data.cacheExpiresAt }); @@ -4769,7 +4848,7 @@ export class CopilotAgentSession extends Disposable { // present at runtime. Forward the per-category snapshots on `_meta` so the client can keep the // account quota UI current. Mirrors the extension-host CLI path, which feeds these into its quota service. const quotaSnapshots = normalizeQuotaSnapshots((e.data as unknown as Record).quotaSnapshots); - const turn = this._currentTurn.value; + const turn = isUnmappedSubagent ? this._currentTurn.value : this._owningRootTurn(parentToolCallId); if (typeof e.data.model === 'string' && e.data.model) { this._lastSeenModelId = e.data.model; @@ -4795,11 +4874,13 @@ export class CopilotAgentSession extends Disposable { // subagent call counts toward the turn under its own model without // being counted twice by the parent and subagent emits below. turn?.addTokenTotals(eventContext.model, eventContext); + const directUsage = isUnmappedSubagent ? undefined : this._directUsageFor(parentToolCallId, true); + directUsage?.add(eventContext.model, eventContext, copilotUsage?.totalNanoAiu); // Builds a usage object carrying the given context's tokens/model plus // the credit total for the given scope. `copilotUsage` is the scope's // Copilot billing metadata, or `undefined` when nothing is billed yet. - const buildUsage = (context: UsageContext, scopedCopilotUsage: UsageInfoMeta['copilotUsage'], isParentScope: boolean): UsageInfo => { + const buildUsage = (context: UsageContext, scopedCopilotUsage: UsageInfoMeta['copilotUsage'], isParentScope: boolean, directOwnerToolCallId: string | undefined): UsageInfo => { const metadata: UsageInfoMeta = {}; if (typeof context.cost === 'number') { metadata.cost = context.cost; @@ -4819,6 +4900,15 @@ export class CopilotAgentSession extends Disposable { if (turnTokenTotals) { metadata.turnTokenTotals = turnTokenTotals; } + const directUsage = this._directUsageFor(directOwnerToolCallId, false); + const directTurnTokenTotals = directUsage?.tokenTotals; + if (directTurnTokenTotals) { + metadata.directTurnTokenTotals = directTurnTokenTotals; + } + const directNanoAiu = directUsage?.copilotNanoAiu; + if (directNanoAiu !== undefined) { + metadata.directCopilotUsage = { totalNanoAiu: directNanoAiu }; + } return { inputTokens: context.inputTokens, outputTokens: context.outputTokens, @@ -4835,36 +4925,36 @@ export class CopilotAgentSession extends Disposable { // the terminal `session.idle` can beat. if (turn && copilotUsage) { turn.copilotNanoAiu += copilotUsage.totalNanoAiu; - if (parentToolCallId) { - const scopedTotal = (turn.subagentNanoAiuByToolCallId.get(parentToolCallId) ?? 0) + copilotUsage.totalNanoAiu; - turn.subagentNanoAiuByToolCallId.set(parentToolCallId, scopedTotal); - } } // Parent turn aggregate: a subagent event must not replace the parent // turn's own model/context-token usage, so preserve the parent's context. - const parentContext = parentToolCallId ? (turn?.parentContextUsage ?? {}) : eventContext; - const parentUsage = buildUsage(parentContext, this._parentCopilotUsageMeta(), true); - lastParentUsage = parentUsage; - lastParentUsageTurnId = this._turnId; - this._emitAction({ - type: ActionType.ChatUsage, - turnId: this._turnId, - usage: parentUsage, - }); + if (turn) { + const parentContext = (parentToolCallId || isUnmappedSubagent) ? (turn.parentContextUsage ?? {}) : eventContext; + const parentUsage = buildUsage(parentContext, this._parentCopilotUsageMeta(), true, undefined); + lastParentUsage = parentUsage; + lastParentUsageTurnId = this._turnId; + this._emitAction({ + type: ActionType.ChatUsage, + turnId: this._turnId, + usage: parentUsage, + }); + } // Subagent component: additionally report the subagent's own running // total to its child session. The SDK's session metrics carry no // per-agent breakdown, so this is the only source for it. if (parentToolCallId) { - const scopedTotal = turn?.subagentNanoAiuByToolCallId.get(parentToolCallId); + const scopedTotal = directUsage?.copilotNanoAiu; const subagentCopilotUsage = copilotUsage && scopedTotal !== undefined ? { ...copilotUsage, totalNanoAiu: scopedTotal } : undefined; + const subagentUsage = buildUsage(eventContext, subagentCopilotUsage, false, parentToolCallId); + this._lastSubagentUsageByToolCallId.set(parentToolCallId, subagentUsage); this._emitAction({ type: ActionType.ChatUsage, turnId: this._turnId, - usage: buildUsage(eventContext, subagentCopilotUsage, false), + usage: subagentUsage, }, parentToolCallId); } })); @@ -4931,17 +5021,29 @@ export class CopilotAgentSession extends Disposable { // only, rather than being carried onto whatever runs next and inflating an unrelated // response footer by what is often the session's single most expensive call. this._register(wrapper.onSessionCompactionComplete(async e => { - if (e.agentId || e.data.success === false) { + if (e.data.success === false) { + return; + } + this._resumeSubagentForEvent(e); + const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); + if (e.agentId && !parentToolCallId) { return; } const copilotUsage = readCopilotUsage(e.data.compactionTokensUsed); - const turn = this._currentTurn.value; + const turn = this._owningRootTurn(parentToolCallId); const compactionTokens = e.data.compactionTokensUsed; - turn?.addTokenTotals(compactionTokens?.model ?? this._lastSeenModelId, { + const model = compactionTokens?.model ?? this._lastSeenModelId; + const usageContext: UsageContext = { inputTokens: compactionTokens?.inputTokens, outputTokens: compactionTokens?.outputTokens, cacheReadTokens: compactionTokens?.cacheReadTokens, - }); + }; + turn?.addTokenTotals(model, usageContext); + const directUsage = this._directUsageFor(parentToolCallId, true); + directUsage?.add(model, usageContext, copilotUsage?.totalNanoAiu); + if (turn && copilotUsage) { + turn.copilotNanoAiu += copilotUsage.totalNanoAiu; + } // Report the turn's cost before awaiting anything. The terminal `session.idle` // can arrive while the metrics read is in flight and close the turn, after // which the reducer drops usage for it — so a compaction whose turn ends @@ -4951,7 +5053,9 @@ export class CopilotAgentSession extends Disposable { const turnId = this._turnId; const parentCopilotUsage = this._parentCopilotUsageMeta(); const turnTokenTotals = this._currentTurn.value?.tokenTotals; - if (!turnId || (!parentCopilotUsage && !turnTokenTotals)) { + const directTurnTokenTotals = this._currentTurn.value?.directUsage.tokenTotals; + const directNanoAiu = this._currentTurn.value?.directUsage.copilotNanoAiu; + if (!turnId || (!parentCopilotUsage && !turnTokenTotals && !directTurnTokenTotals && directNanoAiu === undefined)) { return undefined; } // Preserve the parent turn's own model/context tokens: the compaction call's tokens describe @@ -4964,6 +5068,8 @@ export class CopilotAgentSession extends Disposable { ...(base?._meta ?? {}), ...(parentCopilotUsage ? { copilotUsage: parentCopilotUsage } : {}), ...(turnTokenTotals ? { turnTokenTotals } : {}), + ...(directTurnTokenTotals ? { directTurnTokenTotals } : {}), + ...(directNanoAiu !== undefined ? { directCopilotUsage: { totalNanoAiu: directNanoAiu } } : {}), }, }; lastParentUsage = usage; @@ -4976,8 +5082,33 @@ export class CopilotAgentSession extends Disposable { return turnId; }; - if (turn && copilotUsage) { - turn.copilotNanoAiu += copilotUsage.totalNanoAiu; + if (parentToolCallId && directUsage) { + const priorUsage = this._lastSubagentUsageByToolCallId.get(parentToolCallId); + const metadata: UsageInfoMeta = { ...(priorUsage?._meta ?? {}) }; + if (directUsage.tokenTotals) { + metadata.directTurnTokenTotals = directUsage.tokenTotals; + } + if (directUsage.copilotNanoAiu !== undefined) { + metadata.directCopilotUsage = { totalNanoAiu: directUsage.copilotNanoAiu }; + metadata.copilotUsage = { + ...(metadata.copilotUsage ?? {}), + ...(copilotUsage ?? {}), + totalNanoAiu: directUsage.copilotNanoAiu, + }; + } + const usage: UsageInfo = { + ...priorUsage, + model: priorUsage?.model ?? model, + ...(Object.keys(metadata).length > 0 ? { _meta: metadata } : {}), + }; + this._lastSubagentUsageByToolCallId.set(parentToolCallId, usage); + this._emitAction({ + type: ActionType.ChatUsage, + turnId: this._turnId, + usage, + }, parentToolCallId); + } + if (turn) { emitParentUsage(); } // Then pick up the session-wide total, which also covers a compaction billed diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index c1e5ba8d39d8c7..a4d7c3eec02fc7 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -342,6 +342,11 @@ suite('Agent host _meta readers', () => { { model: 'claude-sonnet-4.6', inputTokens: 40, cachedTokens: 0, outputTokens: 12 }, ]; assert.deepStrictEqual(readUsageInfoMeta(usage({ turnTokenTotals: totals })).turnTokenTotals, totals); + assert.deepStrictEqual(readUsageInfoMeta(usage({ directTurnTokenTotals: totals })).directTurnTokenTotals, totals); + assert.deepStrictEqual( + readUsageInfoMeta(usage({ directCopilotUsage: { totalNanoAiu: 123 } })).directCopilotUsage, + { totalNanoAiu: 123 }, + ); }); test('drops rows that are not fully formed, and reports nothing when none survive', () => { @@ -361,6 +366,8 @@ suite('Agent host _meta readers', () => { assert.deepStrictEqual(meta.turnTokenTotals, [{ model: 'gpt-5', inputTokens: 7, cachedTokens: 0, outputTokens: 3 }]); assert.strictEqual(readUsageInfoMeta(usage({ turnTokenTotals: [{ model: 'gpt-5' }] })).turnTokenTotals, undefined); assert.strictEqual(readUsageInfoMeta(usage({ turnTokenTotals: 'nope' })).turnTokenTotals, undefined); + assert.strictEqual(readUsageInfoMeta(usage({ directTurnTokenTotals: 'nope' })).directTurnTokenTotals, undefined); + assert.strictEqual(readUsageInfoMeta(usage({ directCopilotUsage: { totalNanoAiu: 'nope' } })).directCopilotUsage, undefined); assert.strictEqual(readUsageInfoMeta(usage({})).turnTokenTotals, undefined); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 019c4818599d41..42ad679e04d4f3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -21,7 +21,7 @@ import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import type { SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; +import { ActionType, type ChatAction, type ChatUsageAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, buildSubagentChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; @@ -242,6 +242,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { assert.strictEqual(data.agentSessionId, 'session-1'); assert.strictEqual(data.chatSessionId, getTelemetryChatSessionId(defaultChatUri)); assert.strictEqual(data.turnId, 'turn-1'); + assert.strictEqual(data.parentTurnId, undefined); assert.strictEqual(data.result, 'success'); assert.deepStrictEqual(capturedModel(data), { trusted: true, value: 'gpt-5.5' }); assert.strictEqual(data.modelSelectionKind, 'explicit'); @@ -369,6 +370,99 @@ suite('AgentSideEffects — turn tracker telemetry', () => { ]); }); + test('correlates first-level and nested subagent turns with their immediate parent', () => { + setupSession(); + startTurn('turn-parent'); + + const level1ChatUri = buildSubagentChatUri(sessionUri, 'call-level-1'); + stateManager.addChat(sessionKey, level1ChatUri); + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(defaultChatUri), + toolCallId: 'call-level-1', + agentName: 'explore', + agentDisplayName: 'Explore', + }); + const level1TurnId = stateManager.getActiveTurnId(level1ChatUri); + assert.ok(level1TurnId); + + const level2ChatUri = buildSubagentChatUri(sessionUri, 'call-level-2'); + stateManager.addChat(sessionKey, level2ChatUri); + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(defaultChatUri), + toolCallId: 'call-level-2', + parentToolCallId: 'call-level-1', + agentName: 'explore', + agentDisplayName: 'Explore', + }); + const level2TurnId = stateManager.getActiveTurnId(level2ChatUri); + assert.ok(level2TurnId); + + const directUsage = (turnId: string, inputTokens: number): ChatUsageAction => ({ + type: ActionType.ChatUsage, + turnId, + usage: { + _meta: { + directTurnTokenTotals: [{ model: 'gpt-5.5', inputTokens, cachedTokens: 0, outputTokens: 1 }], + }, + }, + }); + fire(directUsage('turn-parent', 10)); + fire(directUsage(level1TurnId, 20), level1ChatUri); + fire(directUsage(level2TurnId, 30), level2ChatUri); + + fire({ type: ActionType.ChatTurnComplete, turnId: level2TurnId, duration: 1000 }, level2ChatUri); + agent.fireProgress({ + kind: 'subagent_resumed', + chat: URI.parse(defaultChatUri), + toolCallId: 'call-level-2', + }); + const resumedLevel2TurnId = stateManager.getActiveTurnId(level2ChatUri); + assert.ok(resumedLevel2TurnId); + fire({ type: ActionType.ChatTurnComplete, turnId: resumedLevel2TurnId, duration: 1000 }, level2ChatUri); + fire({ type: ActionType.ChatTurnComplete, turnId: level1TurnId, duration: 1000 }, level1ChatUri); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-parent', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { + turnId: data.turnId, + parentTurnId: data.parentTurnId, + parentToolCallId: data.parentToolCallId, + directPromptTokenCount: data.directPromptTokenCount, + }; + }), [ + { turnId: level2TurnId, parentTurnId: level1TurnId, parentToolCallId: 'call-level-2', directPromptTokenCount: 30 }, + { turnId: resumedLevel2TurnId, parentTurnId: level1TurnId, parentToolCallId: 'call-level-2', directPromptTokenCount: undefined }, + { turnId: level1TurnId, parentTurnId: 'turn-parent', parentToolCallId: 'call-level-1', directPromptTokenCount: 20 }, + { turnId: 'turn-parent', parentTurnId: undefined, parentToolCallId: undefined, directPromptTokenCount: 10 }, + ]); + }); + + test('omits subagent parent correlation when the immediate parent is unavailable', () => { + setupSession(); + startTurn('turn-parent'); + + const childChatUri = buildSubagentChatUri(sessionUri, 'call-orphaned-child'); + stateManager.addChat(sessionKey, childChatUri); + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(defaultChatUri), + toolCallId: 'call-orphaned-child', + parentToolCallId: 'unknown-parent-call', + agentName: 'explore', + agentDisplayName: 'Explore', + }); + const childTurnId = stateManager.getActiveTurnId(childChatUri); + assert.ok(childTurnId); + fire({ type: ActionType.ChatTurnComplete, turnId: childTurnId, duration: 1000 }, childChatUri); + + const data = completedEvents()[0].data as Record; + assert.strictEqual(data.parentTurnId, undefined); + assert.strictEqual(data.parentToolCallId, 'call-orphaned-child'); + }); + test('emits turnCompleted with the multi-root working-directory shape', () => { setupSession(true, ['file:///work/app', 'file:///work/api']); startTurn('turn-mr', 'hello'); @@ -537,6 +631,86 @@ suite('AgentSideEffects — turn tracker telemetry', () => { ]); }); + test('reports non-overlapping direct usage snapshots for parent and subagent turns', () => { + setupSession(); + const subagentChatUri = buildSubagentChatUri(sessionUri, 'tool-call-direct'); + stateManager.addChat(sessionKey, subagentChatUri); + + startTurn('turn-parent'); + startTurn('turn-subagent', 'hello', undefined, subagentChatUri); + + fire({ + type: ActionType.ChatUsage, + turnId: 'turn-parent', + usage: { + _meta: { + copilotUsage: { totalNanoAiu: 1_000 }, + directCopilotUsage: { totalNanoAiu: 400 }, + directTurnTokenTotals: [ + { model: 'gpt-5.5', inputTokens: 100, cachedTokens: 60, outputTokens: 20 }, + { model: 'gpt-5.5-mini', inputTokens: 30, cachedTokens: 10, outputTokens: 5 }, + ], + }, + }, + }); + fire({ + type: ActionType.ChatUsage, + turnId: 'turn-subagent', + usage: { + _meta: { + directCopilotUsage: { totalNanoAiu: 300 }, + directTurnTokenTotals: [ + { model: 'gpt-5.5-mini', inputTokens: 70, cachedTokens: 20, outputTokens: 10 }, + ], + }, + }, + }, subagentChatUri); + fire({ + type: ActionType.ChatUsage, + turnId: 'turn-subagent', + usage: { + _meta: { + directCopilotUsage: { totalNanoAiu: 600 }, + directTurnTokenTotals: [ + { model: 'gpt-5.5-mini', inputTokens: 150, cachedTokens: 50, outputTokens: 25 }, + ], + }, + }, + }, subagentChatUri); + + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-subagent', duration: 1000 }, subagentChatUri); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-parent', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { + turnId: data.turnId, + billedNanoAiu: data.billedNanoAiu, + directBilledNanoAiu: data.directBilledNanoAiu, + directPromptTokenCount: data.directPromptTokenCount, + directPromptCacheTokenCount: data.directPromptCacheTokenCount, + directCompletionTokenCount: data.directCompletionTokenCount, + }; + }), [ + { + turnId: 'turn-subagent', + billedNanoAiu: undefined, + directBilledNanoAiu: 600, + directPromptTokenCount: 150, + directPromptCacheTokenCount: 50, + directCompletionTokenCount: 25, + }, + { + turnId: 'turn-parent', + billedNanoAiu: 1_000, + directBilledNanoAiu: 400, + directPromptTokenCount: 130, + directPromptCacheTokenCount: 70, + directCompletionTokenCount: 25, + }, + ]); + }); + test('emits result=cancelled on ChatTurnCancelled', () => { setupSession(); startTurn('turn-1', 'hello', 'auto'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 3ed044a5a6c393..229c5eca0c2cd4 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2026,6 +2026,8 @@ suite('CopilotAgentSession', () => { _meta: { copilotUsage: { totalNanoAiu: 250_000_000, sessionTotalNanoAiu: 250_000_000 }, turnTokenTotals: [{ model: 'claude-sonnet-4.6', inputTokens: 9000, cachedTokens: 0, outputTokens: 400 }], + directTurnTokenTotals: [{ model: 'claude-sonnet-4.6', inputTokens: 9000, cachedTokens: 0, outputTokens: 400 }], + directCopilotUsage: { totalNanoAiu: 250_000_000 }, }, }, }); @@ -2060,6 +2062,8 @@ suite('CopilotAgentSession', () => { copilotUsage: { totalNanoAiu: 750_000_000, sessionTotalNanoAiu: 750_000_000 }, // The compaction call reported no tokens of its own, so only the model call shows. turnTokenTotals: [{ model: 'claude-sonnet-4.6', inputTokens: 10, cachedTokens: 0, outputTokens: 20 }], + directTurnTokenTotals: [{ model: 'claude-sonnet-4.6', inputTokens: 10, cachedTokens: 0, outputTokens: 20 }], + directCopilotUsage: { totalNanoAiu: 750_000_000 }, }, }); }); @@ -2111,6 +2115,8 @@ suite('CopilotAgentSession', () => { // The turn bills only its own call; the compaction is visible in the session total. copilotUsage: { totalNanoAiu: 500_000_000, sessionTotalNanoAiu: 133_968_375_000 }, turnTokenTotals: [{ model: 'claude-opus-4.6', inputTokens: 10, cachedTokens: 0, outputTokens: 20 }], + directTurnTokenTotals: [{ model: 'claude-opus-4.6', inputTokens: 10, cachedTokens: 0, outputTokens: 20 }], + directCopilotUsage: { totalNanoAiu: 500_000_000 }, }, }); }); @@ -2138,6 +2144,8 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(usageActions.at(-1)?.usage._meta, { copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 3_000_000_000 }, turnTokenTotals: [{ model: 'claude-opus-4.6', inputTokens: 1, cachedTokens: 0, outputTokens: 1 }], + directTurnTokenTotals: [{ model: 'claude-opus-4.6', inputTokens: 1, cachedTokens: 0, outputTokens: 1 }], + directCopilotUsage: { totalNanoAiu: 1_000_000_000 }, }); }); @@ -2950,6 +2958,8 @@ suite('CopilotAgentSession', () => { cost: 2, copilotUsage: { totalNanoAiu: 1_250_000_000, sessionTotalNanoAiu: 1_250_000_000 }, turnTokenTotals: [{ model: 'claude-sonnet-4.6', inputTokens: 40, cachedTokens: 5, outputTokens: 60 }], + directTurnTokenTotals: [{ model: 'claude-sonnet-4.6', inputTokens: 40, cachedTokens: 5, outputTokens: 60 }], + directCopilotUsage: { totalNanoAiu: 1_250_000_000 }, }, }); }); @@ -3174,6 +3184,7 @@ suite('CopilotAgentSession', () => { cost: 2, autoModeResolved, turnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 0, outputTokens: 20 }], + directTurnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 0, outputTokens: 20 }], }, }, ], @@ -3213,21 +3224,40 @@ suite('CopilotAgentSession', () => { const usageSignals = signals.flatMap(signal => signal.kind === 'action' && signal.action.type === ActionType.ChatUsage - ? [{ parentToolCallId: signal.parentToolCallId, turnTokenTotals: (signal.action.usage._meta as UsageInfoMeta | undefined)?.turnTokenTotals }] + ? [{ + parentToolCallId: signal.parentToolCallId, + turnTokenTotals: (signal.action.usage._meta as UsageInfoMeta | undefined)?.turnTokenTotals, + directTurnTokenTotals: (signal.action.usage._meta as UsageInfoMeta | undefined)?.directTurnTokenTotals, + }] : []); assert.deepStrictEqual(usageSignals, [ - { parentToolCallId: undefined, turnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 4, outputTokens: 20 }] }, - { parentToolCallId: undefined, turnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 110, cachedTokens: 4, outputTokens: 220 }] }, + { + parentToolCallId: undefined, + turnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 4, outputTokens: 20 }], + directTurnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 4, outputTokens: 20 }], + }, + { + parentToolCallId: undefined, + turnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 110, cachedTokens: 4, outputTokens: 220 }], + directTurnTokenTotals: [{ model: 'claude-opus-4.8', inputTokens: 110, cachedTokens: 4, outputTokens: 220 }], + }, { parentToolCallId: undefined, turnTokenTotals: [ { model: 'claude-opus-4.8', inputTokens: 110, cachedTokens: 4, outputTokens: 220 }, { model: 'gpt-5.5', inputTokens: 5, cachedTokens: 0, outputTokens: 7 }, ], + directTurnTokenTotals: [ + { model: 'claude-opus-4.8', inputTokens: 110, cachedTokens: 4, outputTokens: 220 }, + ], }, // The subagent's own report describes just its component of the turn. - { parentToolCallId: 'tc-subagent', turnTokenTotals: undefined }, + { + parentToolCallId: 'tc-subagent', + turnTokenTotals: undefined, + directTurnTokenTotals: [{ model: 'gpt-5.5', inputTokens: 5, cachedTokens: 0, outputTokens: 7 }], + }, ]); }); @@ -3252,6 +3282,9 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual((usageActions.at(-1)?.usage._meta as UsageInfoMeta | undefined)?.turnTokenTotals, [ { model: 'claude-opus-4.8', inputTokens: 3, cachedTokens: 0, outputTokens: 4 }, ]); + assert.deepStrictEqual((usageActions.at(-1)?.usage._meta as UsageInfoMeta | undefined)?.directTurnTokenTotals, [ + { model: 'claude-opus-4.8', inputTokens: 3, cachedTokens: 0, outputTokens: 4 }, + ]); }); test('reports the parent turn aggregate and additionally the per-subagent component', async () => { @@ -3304,6 +3337,7 @@ suite('CopilotAgentSession', () => { inputTokens: signal.action.usage.inputTokens, outputTokens: signal.action.usage.outputTokens, totalNanoAiu: (signal.action.usage._meta as UsageInfoMeta | undefined)?.copilotUsage?.totalNanoAiu, + directNanoAiu: (signal.action.usage._meta as UsageInfoMeta | undefined)?.directCopilotUsage?.totalNanoAiu, }]; }); @@ -3311,15 +3345,313 @@ suite('CopilotAgentSession', () => { // its credits cover every call the turn caused (its own plus every subagent's). // They land on the synchronous emit, so a turn ending mid-refresh cannot lose them. assert.deepStrictEqual(usageSignals, [ - { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000 }, - { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000 }, - { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000 }, - { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 5, outputTokens: 7, totalNanoAiu: 200_000_000 }, - { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000 }, - { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000 }, - { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 6, outputTokens: 8, totalNanoAiu: 500_000_000 }, - { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000 }, + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000, directNanoAiu: 500_000_000 }, + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000, directNanoAiu: 500_000_000 }, + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000, directNanoAiu: 500_000_000 }, + { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 5, outputTokens: 7, totalNanoAiu: 200_000_000, directNanoAiu: 200_000_000 }, + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000, directNanoAiu: 500_000_000 }, + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000, directNanoAiu: 500_000_000 }, + { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 6, outputTokens: 8, totalNanoAiu: 500_000_000, directNanoAiu: 500_000_000 }, + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000, directNanoAiu: 500_000_000 }, + ]); + }); + + test('starts direct subagent usage over when a retained child resumes', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-1'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + copilotUsage: { totalNanoAiu: 200_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + mockSession.fire('subagent.completed', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + durationMs: 1, + totalTokens: 12, + totalToolCalls: 0, + } as SessionEventPayload<'subagent.completed'>['data'], { agentId: 'agent-1' }); + + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 6, + outputTokens: 8, + copilotUsage: { totalNanoAiu: 300_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + + const childUsage = signals.filter((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === 'tc-subagent' + && signal.action.type === ActionType.ChatUsage + ); + const resumed = childUsage.at(-1)?.action; + assert.ok(resumed?.type === ActionType.ChatUsage); + const meta = resumed.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(meta?.directTurnTokenTotals, [ + { model: 'gpt-5.5', inputTokens: 6, cachedTokens: 0, outputTokens: 8 }, + ]); + assert.deepStrictEqual(meta?.directCopilotUsage, { totalNanoAiu: 300_000_000 }); + }); + + test('keeps direct subagent usage after the root turn completes', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-root'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-background', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Background tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-background' }); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + copilotUsage: { totalNanoAiu: 200_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-background' }); + mockSession.fire('session.idle', { aborted: false } as SessionEventPayload<'session.idle'>['data']); + + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 6, + outputTokens: 8, + copilotUsage: { totalNanoAiu: 300_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-background' }); + + const childUsage = signals.filter((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === 'tc-background' + && signal.action.type === ActionType.ChatUsage + ); + const latest = childUsage.at(-1)?.action; + assert.ok(latest?.type === ActionType.ChatUsage); + const meta = latest.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(meta?.directTurnTokenTotals, [ + { model: 'gpt-5.5', inputTokens: 11, cachedTokens: 0, outputTokens: 15 }, + ]); + assert.deepStrictEqual(meta?.directCopilotUsage, { totalNanoAiu: 500_000_000 }); + }); + + test('does not fold an old background child into a newly active root', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-old-root'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-old-background', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Background tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-old-background' }); + mockSession.fire('session.idle', { aborted: false } as SessionEventPayload<'session.idle'>['data']); + + session.resetTurnState('turn-new-root'); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.8', + inputTokens: 10, + outputTokens: 2, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 100, + outputTokens: 20, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-old-background' }); + + const newRootUsage = signals.filter((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === undefined + && signal.action.type === ActionType.ChatUsage + && signal.action.turnId === 'turn-new-root' + ).at(-1); + assert.ok(newRootUsage?.action.type === ActionType.ChatUsage); + const meta = newRootUsage.action.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(meta?.turnTokenTotals, [ + { model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 0, outputTokens: 2 }, + ]); + assert.deepStrictEqual(meta?.directTurnTokenTotals, [ + { model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 0, outputTokens: 2 }, + ]); + }); + + test('keeps unmapped subagent usage in root inclusive totals without direct attribution', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-root'); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 100, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 400_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'unknown-agent' }); + + const usageSignals = signals.filter((signal): signal is IAgentActionSignal => + signal.kind === 'action' && signal.action.type === ActionType.ChatUsage + ); + assert.strictEqual(usageSignals.some(signal => signal.parentToolCallId !== undefined), false); + const rootUsage = usageSignals.at(-1)?.action; + assert.ok(rootUsage?.type === ActionType.ChatUsage); + const meta = rootUsage.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(meta?.turnTokenTotals, [ + { model: 'gpt-5.5', inputTokens: 100, cachedTokens: 0, outputTokens: 20 }, + ]); + assert.deepStrictEqual(meta?.copilotUsage, { totalNanoAiu: 400_000_000 }); + assert.strictEqual(meta?.directTurnTokenTotals, undefined); + assert.strictEqual(meta?.directCopilotUsage, undefined); + }); + + test('routes legacy parentToolCallId usage to the child direct bucket', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-root'); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 9, + outputTokens: 3, + parentToolCallId: 'tc-legacy', + copilotUsage: { totalNanoAiu: 400_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + const childUsage = signals.find((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === 'tc-legacy' + && signal.action.type === ActionType.ChatUsage + ); + assert.ok(childUsage?.action.type === ActionType.ChatUsage); + const meta = childUsage.action.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(meta?.directTurnTokenTotals, [ + { model: 'gpt-5.5', inputTokens: 9, cachedTokens: 0, outputTokens: 3 }, + ]); + assert.deepStrictEqual(meta?.directCopilotUsage, { totalNanoAiu: 400_000_000 }); + const rootUsage = signals.find((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === undefined + && signal.action.type === ActionType.ChatUsage + ); + assert.ok(rootUsage?.action.type === ActionType.ChatUsage); + const rootMeta = rootUsage.action.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(rootMeta?.turnTokenTotals, [ + { model: 'gpt-5.5', inputTokens: 9, cachedTokens: 0, outputTokens: 3 }, + ]); + assert.deepStrictEqual(rootMeta?.copilotUsage, { totalNanoAiu: 400_000_000 }); + }); + + test('does not fold an old background child legacy usage into a newly active root', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-old-root'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-legacy-bg', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Background legacy tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-legacy-bg' }); + mockSession.fire('session.idle', { aborted: false } as SessionEventPayload<'session.idle'>['data']); + + session.resetTurnState('turn-new-root'); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.8', + inputTokens: 10, + outputTokens: 2, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 100, + outputTokens: 20, + parentToolCallId: 'tc-legacy-bg', + copilotUsage: { totalNanoAiu: 400_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + const newRootUsages = signals.filter((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === undefined + && signal.action.type === ActionType.ChatUsage + && signal.action.turnId === 'turn-new-root' + ); + for (const signal of newRootUsages) { + assert.ok(signal.action.type === ActionType.ChatUsage); + const meta = signal.action.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(meta?.turnTokenTotals, [ + { model: 'claude-opus-4.8', inputTokens: 10, cachedTokens: 0, outputTokens: 2 }, + ]); + } + + const childUsage = signals.filter((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === 'tc-legacy-bg' + && signal.action.type === ActionType.ChatUsage + ).at(-1); + assert.ok(childUsage?.action.type === ActionType.ChatUsage); + const childMeta = childUsage.action.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(childMeta?.directTurnTokenTotals, [ + { model: 'gpt-5.5', inputTokens: 100, cachedTokens: 0, outputTokens: 20 }, + ]); + assert.deepStrictEqual(childMeta?.directCopilotUsage, { totalNanoAiu: 400_000_000 }); + }); + + test('attributes subagent compaction to the child direct bucket', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-root'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-compaction', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Compaction tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-compaction' }); + mockSession.fire('assistant.usage', { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + cacheReadTokens: 2, + cost: 3, + copilotUsage: { totalNanoAiu: 100_000_000, tokenDetails: [] }, + } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-compaction' }); + mockSession.fire('session.compaction_complete', { + success: true, + tokensRemoved: 1_000, + compactionTokensUsed: { + model: 'gpt-5.5', + inputTokens: 40_000, + outputTokens: 500, + cacheReadTokens: 10_000, + copilotUsage: { totalNanoAiu: 5_000_000_000 }, + }, + } as unknown as SessionEventPayload<'session.compaction_complete'>['data'], { agentId: 'agent-compaction' }); + + const childUsage = signals.filter((signal): signal is IAgentActionSignal => + signal.kind === 'action' + && signal.parentToolCallId === 'tc-compaction' + && signal.action.type === ActionType.ChatUsage + ).at(-1); + assert.ok(childUsage?.action.type === ActionType.ChatUsage); + assert.deepStrictEqual({ + model: childUsage.action.usage.model, + inputTokens: childUsage.action.usage.inputTokens, + outputTokens: childUsage.action.usage.outputTokens, + cacheReadTokens: childUsage.action.usage.cacheReadTokens, + cost: (childUsage.action.usage._meta as UsageInfoMeta | undefined)?.cost, + }, { + model: 'gpt-5.5', + inputTokens: 5, + outputTokens: 7, + cacheReadTokens: 2, + cost: 3, + }); + const meta = childUsage.action.usage._meta as UsageInfoMeta | undefined; + assert.deepStrictEqual(meta?.directTurnTokenTotals, [ + { model: 'gpt-5.5', inputTokens: 40_005, cachedTokens: 10_002, outputTokens: 507 }, ]); + assert.deepStrictEqual(meta?.directCopilotUsage, { totalNanoAiu: 5_100_000_000 }); + assert.strictEqual(meta?.copilotUsage?.totalNanoAiu, 5_100_000_000); }); test('forwards account quota snapshots on usage metadata', async () => { diff --git a/src/vs/platform/list/browser/listService.ts b/src/vs/platform/list/browser/listService.ts index da82630110aeee..af2dbd92790433 100644 --- a/src/vs/platform/list/browser/listService.ts +++ b/src/vs/platform/list/browser/listService.ts @@ -1170,8 +1170,8 @@ function workbenchTreeDataPreamble(treeExpandMode) === 'doubleClick'), contextViewProvider: contextViewService as IContextViewProvider, findWidgetStyles: defaultFindWidgetStyles, - enableStickyScroll: Boolean(configurationService.getValue(treeStickyScroll)), - stickyScrollMaxItemCount: Number(configurationService.getValue(treeStickyScrollMaxElements)), + enableStickyScroll: options.enableStickyScroll ?? Boolean(configurationService.getValue(treeStickyScroll)), + stickyScrollMaxItemCount: options.stickyScrollMaxItemCount ?? Number(configurationService.getValue(treeStickyScrollMaxElements)), } as TOptions }; } @@ -1321,11 +1321,11 @@ class WorkbenchTreeInternals { if (e.affectsConfiguration(treeExpandMode) && options.expandOnlyOnTwistieClick === undefined) { newOptions = { ...newOptions, expandOnlyOnTwistieClick: configurationService.getValue<'singleClick' | 'doubleClick'>(treeExpandMode) === 'doubleClick' }; } - if (e.affectsConfiguration(treeStickyScroll)) { + if (e.affectsConfiguration(treeStickyScroll) && options.enableStickyScroll === undefined) { const enableStickyScroll = configurationService.getValue(treeStickyScroll); newOptions = { ...newOptions, enableStickyScroll }; } - if (e.affectsConfiguration(treeStickyScrollMaxElements)) { + if (e.affectsConfiguration(treeStickyScrollMaxElements) && options.stickyScrollMaxItemCount === undefined) { const stickyScrollMaxItemCount = Math.max(1, configurationService.getValue(treeStickyScrollMaxElements)); newOptions = { ...newOptions, stickyScrollMaxItemCount }; } diff --git a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts index 5566ad31aef2ee..a1dfec3686a188 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts @@ -650,6 +650,7 @@ suite('WebPageLoader', () => { const handler = window.webContents.setWindowOpenHandler.firstCall.args[0]; assert.deepStrictEqual([ + handler({ url: 'about:blank' }), handler({ url: 'https://allowed.example/popup' }), handler({ url: 'vscode:mcp/install?test' }), handler({ url: 'calculator:' }), @@ -657,6 +658,7 @@ suite('WebPageLoader', () => { { action: 'deny' }, { action: 'deny' }, { action: 'deny' }, + { action: 'deny' }, ]); }); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index fef3a3bc238e8e..892317ce41ae10 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -16,6 +16,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { scrollbarShadow } from '../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -338,6 +339,7 @@ export class ChatView extends AbstractChatView { return { listForeground: active ? activeSessionViewForeground : inactiveSessionViewForeground, listBackground: active ? activeSessionViewBackground : inactiveSessionViewBackground, + listShadow: scrollbarShadow, overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND, inputEditorBackground: inactiveSessionViewBackground, resultEditorBackground: agentsPanelBackground, diff --git a/src/vs/workbench/contrib/chat/browser/promptTimeline/media/promptTimeline.css b/src/vs/workbench/contrib/chat/browser/promptTimeline/media/promptTimeline.css index 85423df4b57ddc..8afae1e7b9f8bf 100644 --- a/src/vs/workbench/contrib/chat/browser/promptTimeline/media/promptTimeline.css +++ b/src/vs/workbench/contrib/chat/browser/promptTimeline/media/promptTimeline.css @@ -12,7 +12,8 @@ bottom: var(--prompt-timeline-bottom, 0); width: var(--prompt-timeline-rail-width, 36px); pointer-events: none; - z-index: 10; + /* Clear the tree sticky-scroll surface (13) and its native scrollbar (14). */ + z-index: 15; } /* Anchors the rail overlay to the chat widget container (added/removed with the rail's lifecycle). diff --git a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineWidgetContrib.ts b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineWidgetContrib.ts index 7dc446336e5393..e8bcb3d16a736c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineWidgetContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineWidgetContrib.ts @@ -50,7 +50,7 @@ export function observePromptTimelineHostWidth( export function isStickyPromptHeaderShown(widget: IChatWidget, configurationService: IConfigurationService): boolean { return supportsPromptTimeline(widget) && configurationService.getValue(PROMPT_TIMELINE_STICKY_SCROLL_SETTING) === true - && configurationService.getValue(ChatConfiguration.ExperimentalStickyScrollEnabled) !== true; + && configurationService.getValue(ChatConfiguration.ExperimentalStickyScrollEnabled) === false; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 778643d1183e00..51074648f81ab8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -11,6 +11,7 @@ import { alert } from '../../../../../base/browser/ui/aria/aria.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IManagedHover } from '../../../../../base/browser/ui/hover/hover.js'; import { CachedListVirtualDelegate, IListElementRenderDetails } from '../../../../../base/browser/ui/list/list.js'; +import { IStickyScrollNodeSourceRange } from '../../../../../base/browser/ui/tree/abstractTree.js'; import { ITreeNode, ITreeRenderer } from '../../../../../base/browser/ui/tree/tree.js'; import { IAction } from '../../../../../base/common/actions.js'; import { coalesce, distinct } from '../../../../../base/common/arrays.js'; @@ -182,6 +183,7 @@ export interface IChatListItemTemplate { readonly username: HTMLElement; readonly detail: HTMLElement; readonly value: HTMLElement; + stickyScrollSource?: HTMLElement; readonly requestTimestampContainer: HTMLElement; readonly contextKeyService: IContextKeyService; readonly instantiationService: IInstantiationService; @@ -579,6 +581,9 @@ export interface IChatRendererDelegate { container: HTMLElement; getListLength(): number; currentChatMode(): ChatModeKind; + isStickyScrollEnabled(): boolean; + refreshStickyScroll(): void; + readonly stickyScrollTopPadding: number; getEditingValue?(): string | undefined; readonly onDidScroll?: Event; @@ -620,6 +625,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(); private readonly responseTemplateDataByRequestId = new Map(); + private readonly stickyScrollSourceRangesByRequestId = new Map(); + private readonly stickyScrollRequestContentById = new Map(); + private readonly pendingStickyScrollSourceRangeRefresh = this._register(new MutableDisposable()); + private readonly pendingStickyScrollStateRefresh = this._register(new MutableDisposable()); private readonly templateDataByRow = new WeakMap(); /** Track pending question carousels by session resource for auto-skip on chat submission */ @@ -769,6 +778,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.CheckpointsEnabled) && supportsForkOrRestoration; const isPendingRequest = isRequestVM(element) && !!element.pendingKind; + const isStickyScrollRow = !!dom.findParentWithClass(templateData.rowContainer, 'monaco-tree-sticky-row'); - templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || isPendingRequest || isSystemInitiatedRequest || !(checkpointEnabled)); + templateData.checkpointContainer.classList.toggle('hidden', isStickyScrollRow || isResponseVM(element) || isPendingRequest || isSystemInitiatedRequest || !(checkpointEnabled)); // Force toolbars to synchronously re-evaluate after context key changes // to avoid size measurement issues from the debounced menu update. @@ -1432,10 +1487,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer setGroupHover(false))); } - const isStickyScrollRow = !!dom.findParentWithClass(templateData.rowContainer, 'monaco-tree-sticky-row'); - // Only show restore container when we have a checkpoint and not editing, and not a pending request. - // Hide it in sticky scroll rows — only the checkpoint toolbar is shown there. const shouldShowRestore = !isStickyScrollRow && this.viewModel?.model.checkpoint && !this.viewModel?.editing && (index === this.delegate.getListLength() - 1) && !isPendingRequest; templateData.checkpointRestoreContainer.classList.toggle('hidden', !(shouldShowRestore && checkpointEnabled)); @@ -1526,6 +1578,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer !isExplicitFileOrImageVariableEntry(variable) && !isPasteVariableEntry(variable)); const isStickyAndEditing = !element.confirmation && isStickyScrollRow && element.id === this.viewModel?.editing?.id; if (!element.confirmation && !isStickyAndEditing) { - const markdown = isChatFollowup(element.message) ? - element.message.message : - this.markdownDecorationsRenderer.convertParsedRequestToMarkdown(element.sessionResource, element.message); - const attachmentSummary = !element.messageText.trim() && !explicitFileOrImageVariables.length ? getExplicitFileOrImageAttachmentSummary(element.variables) : undefined; - const requestMarkdown = markdown.trim() ? markdown : attachmentSummary; + const requestMarkdown = this.getRequestMarkdown(element, explicitFileOrImageVariables); if (requestMarkdown) { content = [{ content: new MarkdownString(requestMarkdown), kind: 'markdownContent' }]; } @@ -2045,27 +2095,29 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer other.kind === 'markdownContent', @@ -2145,8 +2198,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0) { + if (!isStickyScrollRow && !element.pendingKind && !element.confirmation && this.rendererOptions.renderStyle !== 'minimal' && templateData.value.childElementCount > 0) { const timestamp = renderChatRequestTimestamp(templateData.requestTimestampContainer, element.requestTimestamp); if (timestamp?.hoverText) { templateData.elementDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), timestamp.element, timestamp.hoverText)); @@ -2220,6 +2281,112 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + if (templateData.currentElement === element && templateData.rowContainer.isConnected) { + this.updateStickyScrollSourceRange(element, templateData); + } + })); + return; + } + + const requestBounds = requestBubble.getBoundingClientRect(); + if (isStickyScrollRow) { + const stickyRowBounds = stickyScrollRow.getBoundingClientRect(); + const visibleWidth = Math.min(requestBounds.right, stickyRowBounds.right) - Math.max(requestBounds.left, stickyRowBounds.left); + const visibleHeight = Math.min(requestBounds.bottom, stickyRowBounds.bottom) - Math.max(requestBounds.top, stickyRowBounds.top); + if (visibleHeight <= 0 || visibleWidth <= 0) { + this.rejectStickyScrollSourceRange(element.id, true); + return; + } + + const previousRange = this.stickyScrollSourceRangesByRequestId.get(element.id); + const stickyNodeHeight = this.delegate.stickyScrollTopPadding + requestBounds.height; + if (!previousRange || previousRange.stickyNodeHeight !== stickyNodeHeight) { + this.stickyScrollSourceRangesByRequestId.set(element.id, { + start: previousRange?.start ?? 0, + end: previousRange?.end ?? this.delegate.stickyScrollTopPadding + requestBounds.height, + stickyNodeHeight, + }); + this.scheduleStickyScrollStateRefresh(); + } + return; + } + + const rowBounds = templateData.rowContainer.getBoundingClientRect(); + const start = Math.max(0, requestBounds.top - rowBounds.top - this.delegate.stickyScrollTopPadding); + const end = requestBounds.bottom - rowBounds.top; + if (requestBounds.height <= 0 || end <= start) { + return; + } + + const stickyNodeHeight = this.stickyScrollSourceRangesByRequestId.get(element.id)?.stickyNodeHeight; + this.stickyScrollSourceRangesByRequestId.set(element.id, { start, end, stickyNodeHeight }); + } + + private rejectStickyScrollSourceRange(requestId: string, refreshStickyScroll: boolean): void { + const alreadyRejected = this.stickyScrollSourceRangesByRequestId.get(requestId) === null; + this.stickyScrollSourceRangesByRequestId.set(requestId, null); + if (refreshStickyScroll && !alreadyRejected) { + this.scheduleStickyScrollStateRefresh(); + } + } + + private scheduleStickyScrollSourceRangeRefresh(): void { + if (!this.delegate.isStickyScrollEnabled()) { + return; + } + + this.pendingStickyScrollSourceRangeRefresh.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(this.delegate.container), () => { + this.refreshStickyScrollSourceRanges(); + this.delegate.refreshStickyScroll(); + }); + } + + private scheduleStickyScrollStateRefresh(): void { + this.pendingStickyScrollStateRefresh.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(this.delegate.container), () => { + this.delegate.refreshStickyScroll(); + }); + } + private renderSystemInitiatedRequest(element: IChatRequestViewModel, templateData: IChatListItemTemplate) { dom.clearNode(templateData.value); if (templateData.renderedParts) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 584dcdfa662861..2727e2b812efe6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -43,6 +43,8 @@ import { sanitizeChatClipboardFragment } from './chatClipboard.js'; import { ChatEditorOptions } from './chatOptions.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; +const CHAT_STICKY_SCROLL_TOP_PADDING = 8; + export interface IChatListWidgetStyles { listForeground?: string; listBackground?: string; @@ -493,6 +495,9 @@ export class ChatListWidget extends Disposable { onDidScroll: this.onDidScroll, container: this._container, currentChatMode: options.currentChatMode ?? (() => ChatModeKind.Ask), + isStickyScrollEnabled: () => this.isTreeStickyScrollEnabled(), + refreshStickyScroll: () => this._tree.refreshStickyScroll(), + stickyScrollTopPadding: CHAT_STICKY_SCROLL_TOP_PADDING, getEditingValue: options.getEditingValue, }; @@ -554,7 +559,7 @@ export class ChatListWidget extends Disposable { enableStickyScroll: this.isTreeStickyScrollEnabled(), stickyScrollMaxItemCount: 1, stickyScrollMaxNodeHeight: 150, - stickyScrollShowOnlyWhenNodeFullyHidden: true, + stickyScrollNodeSourceRangeProvider: (element, defaultRange) => this._renderer.getStickyScrollSourceRange(element, defaultRange), indent: 0, expandOnDoubleClick: false, expandOnlyOnTwistieClick: true, @@ -682,6 +687,7 @@ export class ChatListWidget extends Disposable { this._register(this.configurationService.onDidChangeConfiguration((e) => { if (e.affectsConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled) || e.affectsConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING)) { + this._renderer.refreshStickyScrollSourceRanges(true); this._tree.updateOptions({ enableStickyScroll: this.isTreeStickyScrollEnabled() }); } if (e.affectsConfiguration(ChatConfiguration.EditRequests) @@ -936,6 +942,8 @@ export class ChatListWidget extends Disposable { */ rerender(): void { this._tree.rerender(); + this._renderer.refreshStickyScrollSourceRanges(true); + this._tree.rerenderStickyScroll(); } private getItems(): ChatTreeItem[] { @@ -1221,9 +1229,7 @@ export class ChatListWidget extends Disposable { } /** - * Update the list/tree color overrides. Re-applies the same fan-out from - * `listBackground`/`listForeground` to all interaction states that was - * originally configured at construction time. + * Update the list/tree color overrides, including the sticky-scroll surface. */ setStyles(styles: IChatListWidgetStyles): void { this._tree.updateOptions({ @@ -1243,6 +1249,9 @@ export class ChatListWidget extends Disposable { listFocusAndSelectionForeground: styles.listForeground, listActiveSelectionIconForeground: undefined, listInactiveSelectionIconForeground: undefined, + treeStickyScrollBackground: styles.listBackground, + treeStickyScrollBorder: undefined, + treeStickyScrollShadow: styles.listShadow, } }); } @@ -1261,6 +1270,7 @@ export class ChatListWidget extends Disposable { layout(height: number, width: number): void { this._tree.layout(height, width); this._renderer.layout(width ?? this._container.clientWidth); + this._tree.refreshStickyScroll(); } //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index a92f0f7580dec3..8fb1a93f9aedb2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2028,19 +2028,7 @@ export class ChatWidget extends Disposable implements IChatWidget { )); // Wire up ChatWidget-specific list widget events - this._register(this.listWidget.onDidClickRequest(async item => { - // If the click came from a sticky scroll row, scroll to reveal the real - // element and use its template so editing works on the actual row. - if (dom.findParentWithClass(item.rowContainer, 'monaco-tree-sticky-row') && isRequestVM(item.currentElement)) { - this.listWidget.reveal(item.currentElement, 0); - const realTemplate = this.listWidget.getTemplateDataForRequestId(item.currentElement.id); - if (realTemplate) { - this.clickedRequest(realTemplate); - } - return; - } - this.clickedRequest(item); - })); + this._register(this.listWidget.onDidClickRequest(item => this.handleRequestClick(item))); this._register(this.listWidget.onDidRerender(item => { if (isRequestVM(item.currentElement) && this.configurationService.getValue('chat.editRequests') !== 'input') { @@ -2081,6 +2069,19 @@ export class ChatWidget extends Disposable implements IChatWidget { })); } + private handleRequestClick(item: IChatListItemTemplate): void { + const currentElement = item.currentElement; + if (dom.findParentWithClass(item.rowContainer, 'monaco-tree-sticky-row') && isRequestVM(currentElement)) { + this.listWidget.reveal(currentElement, 0); + const realTemplate = this.listWidget.getTemplateDataForRequestId(currentElement.id); + if (realTemplate) { + this.clickedRequest(realTemplate); + } + return; + } + this.clickedRequest(item); + } + startEditing(requestId: string): void { if (this._readOnly) { return; @@ -2564,7 +2565,7 @@ export class ChatWidget extends Disposable implements IChatWidget { /** * Updates the widget's color styles after construction. Propagates the new - * `listForeground`/`listBackground` to the list widget, pushes the new color + * list styles to the list widget, pushes the new color * tokens into `editorOptions` so subscribers (code blocks, result/input editor * backgrounds, container CSS variables) pick them up via `onDidChange`, and * refreshes the CSS variables the chat container exposes for stylesheet rules. @@ -2576,12 +2577,14 @@ export class ChatWidget extends Disposable implements IChatWidget { // update list if needed const listColorsChanged = oldStyles.listBackground !== styles.listBackground || - oldStyles.listForeground !== styles.listForeground; + oldStyles.listForeground !== styles.listForeground || + oldStyles.listShadow !== styles.listShadow; if (listColorsChanged) { this.listWidget?.setStyles({ listForeground: styles.listForeground, listBackground: styles.listBackground, + listShadow: styles.listShadow, }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index f9c8c461361d8c..ac4b1e7ee8037b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -40,6 +40,24 @@ overflow: hidden; } +.interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container.source-node-partially-visible .monaco-tree-sticky-container-shadow { + display: none; +} + +.interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container:not(.source-node-partially-visible) .monaco-tree-sticky-container-shadow { + pointer-events: none; + transform: translateY(var(--vscode-spacing-size80)); +} + +.interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container:not(.source-node-partially-visible) .monaco-tree-sticky-container-shadow::before { + content: ""; + position: absolute; + inset-inline: 0; + inset-block-end: 100%; + height: var(--vscode-spacing-size80); + background-color: var(--vscode-chat-list-background); +} + .interactive-list > .chat-scroll-down { padding: 4px; } @@ -4038,6 +4056,23 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } + .monaco-tree-sticky-row .interactive-item-container.interactive-request .value > .chat-markdown-part.rendered-markdown.chat-request-has-more > p:first-child::after { + content: '\2026'; + } + + .monaco-tree-sticky-row .interactive-item-container.interactive-request { + padding-block: var(--vscode-spacing-size80) 0; + } + + .monaco-tree-sticky-row .interactive-item-container.interactive-request .value .rendered-markdown { + margin-bottom: 0; + } + + .monaco-tree-sticky-row.source-node-partially-visible .interactive-item-container.interactive-request .value .rendered-markdown { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + .monaco-tree-sticky-row .interactive-item-container.interactive-request .value .rendered-markdown .chat-request-text.sticky-editing { font-style: italic; } @@ -4532,7 +4567,6 @@ have to be updated for changes to the rules above, or to support more deeply nes .checkpoint-container.group-hovered, .checkpoint-container:has(.chat-restore-checkpoint-item.confirming), .interactive-item-container.interactive-request:not(.editing):hover .checkpoint-container, - .monaco-tree-sticky-row .checkpoint-container, .monaco-tree-sticky-row .interactive-item-container.interactive-request .request-hover { opacity: 1; } @@ -4549,7 +4583,7 @@ have to be updated for changes to the rules above, or to support more deeply nes outline: none !important; } - div[data-index="0"] .monaco-tl-contents { + div[data-index="0"]:not(.monaco-tree-sticky-row) .monaco-tl-contents { .interactive-item-container.interactive-request:not(.editing) { padding-top: var(--vscode-spacing-size200); } diff --git a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineGutterRail.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineGutterRail.test.ts index a4d4562b72bd65..2d51a523c858a0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineGutterRail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineGutterRail.test.ts @@ -163,6 +163,43 @@ suite('PromptTimelineGutterRail', () => { tabbable: 1, }); }); + + test('keeps the flyout above sticky scroll and the transcript scrollbar', () => { + const host = document.createElement('div'); + host.classList.add('prompt-timeline-host'); + host.style.position = 'fixed'; + host.style.inset = '0 auto auto 0'; + host.style.width = '400px'; + host.style.height = '400px'; + host.style.zIndex = '10000'; + document.body.appendChild(host); + store.add(toDisposable(() => host.remove())); + + const rail = store.add(new PromptTimelineGutterRail()); + host.appendChild(rail.domNode); + rail.setTicks(Array.from({ length: 4 }, (_, index) => tick(index))); + rail.domNode.classList.add('revealed'); + + const transcriptChrome = document.createElement('div'); + transcriptChrome.style.position = 'absolute'; + transcriptChrome.style.inset = '0'; + transcriptChrome.style.zIndex = '14'; + host.appendChild(transcriptChrome); + + const panel = rail.domNode.querySelector('.prompt-timeline-gutter-panel'); + const firstRow = panel?.querySelector('.prompt-timeline-gutter-row-jump'); + assert.ok(panel && firstRow); + const bounds = firstRow.getBoundingClientRect(); + const topElement = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2); + + assert.deepStrictEqual({ + panelReceivesPointer: panel.contains(topElement), + transcriptChromeReceivesPointer: transcriptChrome.contains(topElement), + }, { + panelReceivesPointer: true, + transcriptChromeReceivesPointer: false, + }); + }); }); suite('restDotCount', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineVisibility.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineVisibility.test.ts index 0d03c97018dff9..a5e170cb85f70c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineVisibility.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineVisibility.test.ts @@ -19,7 +19,11 @@ suite('PromptTimeline visibility', () => { } test('is shown only for enabled transcript hosts that render their input below the transcript', () => { - const enabled = new TestConfigurationService({ [PROMPT_TIMELINE_STICKY_SCROLL_SETTING]: true }); + const enabled = new TestConfigurationService({ + [PROMPT_TIMELINE_STICKY_SCROLL_SETTING]: true, + [ChatConfiguration.ExperimentalStickyScrollEnabled]: false, + }); + const pendingTreeStickyScroll = new TestConfigurationService({ [PROMPT_TIMELINE_STICKY_SCROLL_SETTING]: true }); const disabled = new TestConfigurationService({ [PROMPT_TIMELINE_STICKY_SCROLL_SETTING]: false }); const experimentalTreeStickyScroll = new TestConfigurationService({ [PROMPT_TIMELINE_STICKY_SCROLL_SETTING]: true, @@ -28,6 +32,7 @@ suite('PromptTimeline visibility', () => { assert.deepStrictEqual({ chatTranscript: isStickyPromptHeaderShown(widget(ChatAgentLocation.Chat, false), enabled), + pendingTreeStickyScroll: isStickyPromptHeaderShown(widget(ChatAgentLocation.Chat, false), pendingTreeStickyScroll), settingOff: isStickyPromptHeaderShown(widget(ChatAgentLocation.Chat, false), disabled), experimentalTreeStickyScroll: isStickyPromptHeaderShown(widget(ChatAgentLocation.Chat, false), experimentalTreeStickyScroll), // Quick chat and the new-session composer render their input on top, so no header is mounted. @@ -35,6 +40,7 @@ suite('PromptTimeline visibility', () => { otherLocation: isStickyPromptHeaderShown(widget(ChatAgentLocation.EditorInline, false), enabled), }, { chatTranscript: true, + pendingTreeStickyScroll: false, settingOff: false, experimentalTreeStickyScroll: false, inputOnTop: false, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 0537d740c34f9b..9ba5ec875bd7fa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -654,6 +654,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, @@ -722,6 +725,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, @@ -1101,6 +1107,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, @@ -1218,6 +1227,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, @@ -1292,6 +1304,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, @@ -1402,6 +1417,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, @@ -1516,6 +1534,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, @@ -1585,6 +1606,9 @@ suite('ChatListRenderer', () => { onDidScroll: () => toDisposable(() => { }), container, currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, }, undefined, viewModel, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index 12db45d6c45f37..c2380304749ad4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -7,17 +7,22 @@ import assert from 'assert'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; import { IAccessibleViewService } from '../../../../../../platform/accessibility/browser/accessibleView.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { scrollbarShadow } from '../../../../../../platform/theme/common/colorRegistry.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IChatAccessibilityService } from '../../../browser/chat.js'; import { computeScrollDownState, getAnchoredScrollTop, AutoScrollHolds, UserToggleResizeState, ChatListWidget, IChatListWidgetOptions } from '../../../browser/widget/chatListWidget.js'; import { ChatEditorOptions } from '../../../browser/widget/chatOptions.js'; import { IChatService } from '../../../common/chatService/chatService.js'; +import { IChatSideChatService } from '../../../common/chatSideChatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; import { ChatModel } from '../../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -28,6 +33,9 @@ import { ToolDataSource } from '../../../common/tools/languageModelToolsService. import { MockChatService } from '../../common/chatService/mockChatService.js'; import { IChatModelFeedbackSurveyService } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; import { MockChatModelFeedbackSurveyService } from '../feedbackSurvey/mockChatModelFeedbackSurveyService.js'; +import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { PROMPT_TIMELINE_STICKY_SCROLL_SETTING } from '../../../common/promptTimeline.js'; +import '../../../browser/widget/media/chat.css'; function nextFrame(): Promise { return new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); @@ -54,7 +62,7 @@ async function waitForStableLayout(widget: ChatListWidget, maxFrames = 120): Pro suite('ChatListWidget', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createWidget(options: IChatListWidgetOptions = {}) { + function createWidget(options: IChatListWidgetOptions = {}, configure?: (configurationService: TestConfigurationService) => void, isSessionsWindow = false) { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); const configurationService = new TestConfigurationService(); @@ -64,6 +72,7 @@ suite('ChatListWidget', () => { configurationService.setUserConfiguration('chat.checkpoints.showFileChanges', false); configurationService.setUserConfiguration(ChatConfiguration.TurnStatusPills, false); configurationService.setUserConfiguration(ChatConfiguration.Verbose, false); + configure?.(configurationService); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); @@ -75,6 +84,13 @@ suite('ChatListWidget', () => { acceptResponse: () => { }, acceptElicitation: () => { }, }); + if (isSessionsWindow) { + instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: true } as Partial); + instantiationService.stub(IChatSideChatService, { + observeSideChatOrigin: () => constObservable(undefined), + revealSideChatSource: async () => { }, + } as Partial); + } const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); const viewModel = disposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); @@ -85,7 +101,15 @@ suite('ChatListWidget', () => { container.style.width = '500px'; container.style.height = '300px'; container.classList.add('monaco-reduce-motion'); - mainWindow.document.body.appendChild(container); + if (isSessionsWindow) { + const sessionContainer = mainWindow.document.createElement('div'); + sessionContainer.classList.add('interactive-session'); + sessionContainer.appendChild(container); + mainWindow.document.body.appendChild(sessionContainer); + disposables.add(toDisposable(() => sessionContainer.remove())); + } else { + mainWindow.document.body.appendChild(container); + } disposables.add(toDisposable(() => container.remove())); const widget = disposables.add(instantiationService.createInstance(ChatListWidget, container, { @@ -99,6 +123,81 @@ suite('ChatListWidget', () => { return { disposables, model, viewModel, container, widget }; } + async function measureFirstRequestPushOut(firstText: string) { + const { disposables, model, viewModel, container, widget } = createWidget({}, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, true); + }, true); + container.classList.add('interactive-list'); + container.style.setProperty('--vscode-spacing-size80', '8px'); + const firstRequest = model.addRequest({ + text: firstText, + parts: [new ChatRequestTextPart(new OffsetRange(0, firstText.length), new Range(1, 1, 1, firstText.length + 1), firstText)] + }, { variables: [] }, 0); + const firstResponse = Array.from({ length: 40 }, (_, index) => `first response line ${index}`).join('\n\n'); + model.acceptResponseProgress(firstRequest, { kind: 'markdownContent', content: new MarkdownString(firstResponse) }); + firstRequest.response?.complete(); + + const secondText = 'second question'; + const secondRequest = model.addRequest({ + text: secondText, + parts: [new ChatRequestTextPart(new OffsetRange(0, secondText.length), new Range(1, 1, 1, secondText.length + 1), secondText)] + }, { variables: [] }, 1); + const secondResponse = Array.from({ length: 40 }, (_, index) => `second response line ${index}`).join('\n\n'); + model.acceptResponseProgress(secondRequest, { kind: 'markdownContent', content: new MarkdownString(secondResponse) }); + secondRequest.response?.complete(); + + widget.refresh(); + widget.layout(300, 500); + await waitForStableLayout(widget); + const secondRequestItem = viewModel.getItems().filter(isRequestVM)[1]; + const secondRequestTop = widget.getElementTop(secondRequestItem); + assert.notStrictEqual(secondRequestTop, undefined); + widget.scrollTop = secondRequestTop! - widget.renderHeight / 2; + await nextFrame(); + await nextFrame(); + const initialStickyRow = container.querySelector('.monaco-tree-sticky-row'); + const initialStickyBubble = initialStickyRow?.querySelector('.chat-markdown-part.rendered-markdown'); + const firstBlock = initialStickyBubble?.firstElementChild as HTMLElement | null; + assert.ok(initialStickyRow && initialStickyBubble && firstBlock); + const lineHeight = Number.parseFloat(mainWindow.getComputedStyle(firstBlock).lineHeight); + const stickyLineCount = Math.round(firstBlock.getBoundingClientRect().height / lineHeight); + const rowHeight = initialStickyRow.getBoundingClientRect().height; + + const rerenderedRowHeights: number[] = []; + for (let rerender = 0; rerender < 3; rerender++) { + widget.rerender(); + const stickyRow = container.querySelector('.monaco-tree-sticky-row'); + if (stickyRow) { + rerenderedRowHeights.push(stickyRow.getBoundingClientRect().height); + } + } + await nextFrame(); + await nextFrame(); + const settledStickyRow = container.querySelector('.monaco-tree-sticky-row'); + if (settledStickyRow) { + rerenderedRowHeights.push(settledStickyRow.getBoundingClientRect().height); + } + + const offsets = [rowHeight, rowHeight - 1, rowHeight / 2, 1, 0]; + const samples: { offset: number; actualHeight: number; expectedHeight: number; hasVisibleBubble: boolean }[] = []; + for (const offset of offsets) { + widget.scrollTop = secondRequestTop! - offset; + const stickyContainer = container.querySelector('.monaco-tree-sticky-container:not(.empty)'); + const stickyRow = stickyContainer?.querySelector('.monaco-tree-sticky-row'); + const stickyBubble = stickyRow?.querySelector('.chat-markdown-part.rendered-markdown'); + const containerBounds = stickyContainer?.getBoundingClientRect(); + const bubbleBounds = stickyBubble?.getBoundingClientRect(); + const actualHeight = containerBounds?.height ?? 0; + const expectedHeight = Math.min(rowHeight, Math.max(0, offset)); + const hasVisibleBubble = actualHeight === 0 || !!bubbleBounds && !!containerBounds && bubbleBounds.bottom > containerBounds.top && bubbleBounds.top < containerBounds.bottom; + samples.push({ offset, actualHeight, expectedHeight, hasVisibleBubble }); + } + + disposables.dispose(); + return { stickyLineCount, rowHeight, rerenderedRowHeights, samples }; + } + test('auto-scroll holds compose and survive a double release', () => { const holds = new AutoScrollHolds(); const states = [holds.isHeld]; @@ -205,6 +304,521 @@ suite('ChatListWidget', () => { disposables.dispose(); }); + test('keeps tree sticky scroll disabled when the legacy prompt header is selected', () => { + const { disposables, container } = createWidget({}, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, false); + configurationService.setUserConfiguration('workbench.tree.enableStickyScroll', true); + }); + + assert.strictEqual(container.querySelector('.monaco-tree-sticky-container'), null); + + disposables.dispose(); + }); + + test('shows sticky requests that have never entered the render window', async () => { + const { disposables, model, viewModel, container, widget } = createWidget({}, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, true); + }); + const response = Array.from({ length: 80 }, (_, index) => `response paragraph ${index}`).join('\n\n'); + for (let index = 0; index < 4; index++) { + const text = `question ${index}`; + const request = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)] + }, { variables: [] }, index); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(response) }); + request.response?.complete(); + } + + const requestItems = viewModel.getItems().filter(isRequestVM); + const responseItems = viewModel.getItems().filter(isResponseVM); + for (const item of responseItems) { + item.currentRenderedHeight = 2200; + } + const targetRequest = requestItems[2]; + const targetResponse = responseItems[2]; + + widget.refresh(); + widget.layout(300, 500); + await waitForStableLayout(widget); + const targetResponseTop = widget.getElementTop(targetResponse); + assert.notStrictEqual(targetResponseTop, undefined); + const targetWasRenderedBeforeJump = Array.from(container.querySelectorAll('.monaco-list-rows > .monaco-list-row.request')) + .some(row => row.textContent?.includes(targetRequest.messageText)); + + widget.scrollTop = targetResponseTop! + 800; + await nextFrame(); + await nextFrame(); + const stickyRequest = container.querySelector('.monaco-tree-sticky-row'); + + assert.deepStrictEqual({ + targetWasRenderedBeforeJump, + stickyRequestVisible: stickyRequest?.textContent?.includes(targetRequest.messageText), + }, { + targetWasRenderedBeforeJump: false, + stickyRequestVisible: true, + }); + + disposables.dispose(); + }); + + test('uses the request bubble as the sticky source and content', async () => { + const { disposables, model, container, widget } = createWidget({ + styles: { listShadow: scrollbarShadow }, + }, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, true); + configurationService.setUserConfiguration(ChatConfiguration.CheckpointsEnabled, true); + configurationService.setUserConfiguration('workbench.tree.enableStickyScroll', false); + configurationService.setUserConfiguration('workbench.tree.stickyScrollMaxItemCount', 1); + }, true); + container.classList.add('interactive-list'); + container.style.height = '600px'; + container.style.setProperty('--vscode-spacing-size60', '6px'); + container.style.setProperty('--vscode-spacing-size80', '8px'); + container.style.setProperty('--vscode-spacing-size160', '16px'); + container.style.setProperty('--vscode-spacing-size200', '20px'); + container.style.setProperty('--vscode-cornerRadius-medium', '6px'); + const hasStickyShadowRule = Array.from(container.querySelectorAll('style')).some(style => + style.textContent?.includes('.monaco-tree-sticky-container-shadow') && style.textContent.includes('box-shadow')); + const text = Array.from({ length: 8 }, (_, index) => `question with an attachment, paragraph ${index}`).join('\n\n'); + const attachment: IChatRequestVariableEntry = { + kind: 'file', + id: 'attachment', + name: 'attachment.ts', + value: URI.file('/test/attachment.ts'), + }; + const request = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)] + }, { variables: [attachment] }, 0); + const response = Array.from({ length: 40 }, (_, index) => `response line ${index}`).join('\n\n'); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(response) }); + request.response?.complete(); + + widget.refresh(); + widget.layout(600, 500); + await waitForStableLayout(widget); + widget.layout(600, 500); + widget.scrollTop = 0; + await nextFrame(); + + const requestRow = container.querySelector('.monaco-list-rows > .monaco-list-row.request'); + const sourceRequestBubble = requestRow?.querySelector('.chat-markdown-part.rendered-markdown'); + assert.ok(requestRow && sourceRequestBubble); + const rowBounds = requestRow.getBoundingClientRect(); + const sourceBounds = sourceRequestBubble.getBoundingClientRect(); + const sourceStart = sourceBounds.top - rowBounds.top; + const sourceEnd = sourceBounds.bottom - rowBounds.top; + const stickyTopPadding = parseFloat(mainWindow.getComputedStyle(container).getPropertyValue('--vscode-spacing-size80')); + const stickySourceStart = Math.max(0, sourceStart - stickyTopPadding); + assert.ok(sourceStart > stickyTopPadding); + const sourcePaddingTop = mainWindow.getComputedStyle(requestRow.querySelector('.interactive-item-container.interactive-request')!).paddingTop; + + widget.scrollTop = stickySourceStart; + await nextFrame(); + const stickyBeforeSourceLeaves = container.querySelector('.monaco-tree-sticky-row'); + const sourceBubbleTopBeforeSticky = sourceRequestBubble.getBoundingClientRect().top; + + widget.scrollTop = stickySourceStart + 1; + await nextFrame(); + const partiallyVisibleStickyRow = container.querySelector('.monaco-tree-sticky-row'); + const stickyContainer = container.querySelector('.monaco-tree-sticky-container'); + const partialRequestContainer = partiallyVisibleStickyRow?.querySelector('.interactive-item-container.interactive-request'); + const partialRequestValue = partialRequestContainer?.querySelector(':scope > .value'); + const partialRequestBubble = partiallyVisibleStickyRow?.querySelector('.chat-markdown-part.rendered-markdown'); + const partialFirstParagraph = partialRequestBubble?.querySelector('p:first-child'); + const stickyShadow = stickyContainer?.querySelector('.monaco-tree-sticky-container-shadow'); + assert.ok(partiallyVisibleStickyRow && stickyContainer && partialRequestContainer && partialRequestValue && partialRequestBubble && partialFirstParagraph && stickyShadow); + const partialRequestStyle = mainWindow.getComputedStyle(partialRequestContainer); + const partialBubbleStyle = mainWindow.getComputedStyle(partialRequestBubble); + const partialState = { + row: partiallyVisibleStickyRow.classList.contains('source-node-partially-visible'), + container: stickyContainer.classList.contains('source-node-partially-visible'), + sourceExtendsBelowSticky: sourceRequestBubble.getBoundingClientRect().bottom > partialRequestBubble.getBoundingClientRect().bottom, + activationJump: Math.round(partialRequestBubble.getBoundingClientRect().top - sourceBubbleTopBeforeSticky), + paddingTop: partialRequestStyle.paddingTop, + paddingBottom: partialRequestStyle.paddingBottom, + bubbleMarginBottom: partialBubbleStyle.marginBottom, + bubbleBottomRadius: [partialBubbleStyle.borderBottomLeftRadius, partialBubbleStyle.borderBottomRightRadius], + shadowDisplay: mainWindow.getComputedStyle(stickyShadow).display, + shadowTransform: mainWindow.getComputedStyle(stickyShadow).transform, + hasMore: partialRequestBubble.classList.contains('chat-request-has-more'), + continuationContent: mainWindow.getComputedStyle(partialFirstParagraph, '::after').content, + requestVisible: partiallyVisibleStickyRow.textContent?.includes('question with an attachment'), + valueContainsOnlyBubble: partialRequestValue.childElementCount === 1 && partialRequestValue.firstElementChild === partialRequestBubble, + originCount: partiallyVisibleStickyRow.querySelectorAll('.chat-request-origin').length, + attachmentCount: partiallyVisibleStickyRow.querySelectorAll('.chat-request-attachment-cards').length, + timestampCount: partiallyVisibleStickyRow.querySelectorAll('.chat-request-timestamp').length, + checkpointHidden: partiallyVisibleStickyRow.querySelector('.checkpoint-container')?.classList.contains('hidden'), + }; + + const coveredSourceScrollTop = widget.scrollTop + + Math.ceil(sourceRequestBubble.getBoundingClientRect().bottom - partialRequestBubble.getBoundingClientRect().bottom) + + 1; + widget.scrollTop = coveredSourceScrollTop; + await nextFrame(); + const coveredSourceStickyRow = container.querySelector('.monaco-tree-sticky-row'); + const coveredSourceStickyBubble = coveredSourceStickyRow?.querySelector('.chat-markdown-part.rendered-markdown'); + assert.ok(coveredSourceStickyRow && coveredSourceStickyBubble); + const coveredSourceState = { + row: coveredSourceStickyRow.classList.contains('source-node-partially-visible'), + container: stickyContainer.classList.contains('source-node-partially-visible'), + sourceStillVisible: sourceRequestBubble.getBoundingClientRect().bottom > stickyContainer.getBoundingClientRect().top, + sourceExtendsBelowSticky: sourceRequestBubble.getBoundingClientRect().bottom > coveredSourceStickyBubble.getBoundingClientRect().bottom, + bubbleBottomRadius: [ + mainWindow.getComputedStyle(coveredSourceStickyBubble).borderBottomLeftRadius, + mainWindow.getComputedStyle(coveredSourceStickyBubble).borderBottomRightRadius, + ], + }; + + widget.scrollTop = sourceEnd + 1; + await nextFrame(); + const fullyHiddenStickyRow = container.querySelector('.monaco-tree-sticky-row'); + const fullRequestContainer = fullyHiddenStickyRow?.querySelector('.interactive-item-container.interactive-request'); + const fullRequestValue = fullRequestContainer?.querySelector(':scope > .value'); + const fullRequestBubble = fullyHiddenStickyRow?.querySelector('.chat-markdown-part.rendered-markdown'); + assert.ok(fullyHiddenStickyRow && fullRequestContainer && fullRequestValue && fullRequestBubble); + const fullRequestStyle = mainWindow.getComputedStyle(fullRequestContainer); + const fullBubbleStyle = mainWindow.getComputedStyle(fullRequestBubble); + const fullState = { + row: fullyHiddenStickyRow.classList.contains('source-node-partially-visible'), + container: stickyContainer.classList.contains('source-node-partially-visible'), + paddingTop: fullRequestStyle.paddingTop, + paddingBottom: fullRequestStyle.paddingBottom, + bubbleMarginBottom: fullBubbleStyle.marginBottom, + bubbleBottomRadius: [fullBubbleStyle.borderBottomLeftRadius, fullBubbleStyle.borderBottomRightRadius], + shadowDisplay: mainWindow.getComputedStyle(stickyShadow).display, + shadowGap: stickyShadow.getBoundingClientRect().top - stickyContainer.getBoundingClientRect().bottom, + shadowSpacerHeight: mainWindow.getComputedStyle(stickyShadow, '::before').height, + valueContainsOnlyBubble: fullRequestValue.childElementCount === 1 && fullRequestValue.firstElementChild === fullRequestBubble, + originCount: fullyHiddenStickyRow.querySelectorAll('.chat-request-origin').length, + timestampCount: fullyHiddenStickyRow.querySelectorAll('.chat-request-timestamp').length, + }; + + widget.scrollTop = stickySourceStart + 1; + await nextFrame(); + const returnedPartialStickyRow = container.querySelector('.monaco-tree-sticky-row'); + const returnedPartialRequestContainer = returnedPartialStickyRow?.querySelector('.interactive-item-container.interactive-request'); + assert.ok(returnedPartialStickyRow && returnedPartialRequestContainer); + const returnedPartialState = { + row: returnedPartialStickyRow.classList.contains('source-node-partially-visible'), + container: stickyContainer.classList.contains('source-node-partially-visible'), + paddingBottom: mainWindow.getComputedStyle(returnedPartialRequestContainer).paddingBottom, + shadowDisplay: mainWindow.getComputedStyle(stickyShadow).display, + }; + + widget.scrollTop = sourceEnd + 1; + await nextFrame(); + const returnedFullStickyRow = container.querySelector('.monaco-tree-sticky-row'); + const returnedFullRequestContainer = returnedFullStickyRow?.querySelector('.interactive-item-container.interactive-request'); + assert.ok(returnedFullStickyRow && returnedFullRequestContainer); + const returnedFullState = { + row: returnedFullStickyRow.classList.contains('source-node-partially-visible'), + container: stickyContainer.classList.contains('source-node-partially-visible'), + paddingBottom: mainWindow.getComputedStyle(returnedFullRequestContainer).paddingBottom, + shadowDisplay: mainWindow.getComputedStyle(stickyShadow).display, + shadowGap: stickyShadow.getBoundingClientRect().top - stickyContainer.getBoundingClientRect().bottom, + }; + + assert.deepStrictEqual({ + hasStickyShadowRule, + sourceOriginCount: requestRow.querySelectorAll('.chat-request-origin').length, + sourceAttachmentCount: requestRow.querySelectorAll('.chat-request-attachment-cards').length, + sourceCheckpointHidden: requestRow.querySelector('.checkpoint-container')?.classList.contains('hidden'), + sourcePaddingTop, + stickyBeforeSourceLeaves: !!stickyBeforeSourceLeaves, + partialState, + coveredSourceState, + fullState, + returnedPartialState, + returnedFullState, + }, { + hasStickyShadowRule: true, + sourceOriginCount: 1, + sourceAttachmentCount: 1, + sourceCheckpointHidden: false, + sourcePaddingTop: '20px', + stickyBeforeSourceLeaves: false, + partialState: { + row: true, + container: true, + sourceExtendsBelowSticky: true, + activationJump: 0, + paddingTop: '8px', + paddingBottom: '0px', + bubbleMarginBottom: '0px', + bubbleBottomRadius: ['0px', '0px'], + shadowDisplay: 'none', + shadowTransform: 'none', + hasMore: true, + continuationContent: '"…"', + requestVisible: true, + valueContainsOnlyBubble: true, + originCount: 0, + attachmentCount: 0, + timestampCount: 0, + checkpointHidden: true, + }, + coveredSourceState: { + row: false, + container: false, + sourceStillVisible: true, + sourceExtendsBelowSticky: false, + bubbleBottomRadius: ['6px', '6px'], + }, + fullState: { + row: false, + container: false, + paddingTop: '8px', + paddingBottom: '0px', + bubbleMarginBottom: '0px', + bubbleBottomRadius: ['6px', '6px'], + shadowDisplay: 'block', + shadowGap: 8, + shadowSpacerHeight: '8px', + valueContainsOnlyBubble: true, + originCount: 0, + timestampCount: 0, + }, + returnedPartialState: { + row: true, + container: true, + paddingBottom: '0px', + shadowDisplay: 'none', + }, + returnedFullState: { + row: false, + container: false, + paddingBottom: '0px', + shadowDisplay: 'block', + shadowGap: 8, + }, + }); + + disposables.dispose(); + }); + + test('keeps one-line and two-line sticky requests stable through push-out', async () => { + const [oneLine, twoLines] = await Promise.all([ + measureFirstRequestPushOut('short first question'), + measureFirstRequestPushOut('long first question '.repeat(20)), + ]); + + const summarize = (result: typeof oneLine) => ({ + stableRerenders: result.rerenderedRowHeights.length === 4 + && result.rerenderedRowHeights.every(height => Math.abs(height - result.rowHeight) <= 1), + smoothPushOut: result.samples.every(sample => Math.abs(sample.actualHeight - sample.expectedHeight) <= 2), + visibleContent: result.samples.every(sample => sample.hasVisibleBubble), + }); + + assert.deepStrictEqual({ + oneLine: { lineCount: oneLine.stickyLineCount, ...summarize(oneLine) }, + twoLines: { lineCount: twoLines.stickyLineCount, tallerThanOneLine: twoLines.rowHeight > oneLine.rowHeight, ...summarize(twoLines) }, + }, { + oneLine: { lineCount: 1, stableRerenders: true, smoothPushOut: true, visibleContent: true }, + twoLines: { lineCount: 2, tallerThanOneLine: true, stableRerenders: true, smoothPushOut: true, visibleContent: true }, + }); + }); + + test('does not create an empty sticky row for an attachment-only request', async () => { + const { disposables, model, container, widget } = createWidget({}, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, true); + configurationService.setUserConfiguration('workbench.tree.enableStickyScroll', false); + }); + const attachment: IChatRequestVariableEntry = { + kind: 'file', + id: 'attachment-only', + name: 'attachment.ts', + value: URI.file('/test/attachment.ts'), + }; + const request = model.addRequest({ text: '', parts: [] }, { variables: [attachment] }, 0); + const response = Array.from({ length: 40 }, (_, index) => `response line ${index}`).join('\n\n'); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(response) }); + request.response?.complete(); + + widget.refresh(); + widget.layout(300, 500); + await waitForStableLayout(widget); + widget.layout(300, 500); + widget.scrollTop = 0; + await nextFrame(); + + const requestRow = container.querySelector('.monaco-list-rows > .monaco-list-row.request'); + assert.ok(requestRow); + const sourceState = { + attachments: requestRow.querySelectorAll('.chat-request-attachment-cards').length, + requestBubbles: requestRow.querySelectorAll('.chat-markdown-part.rendered-markdown').length, + }; + + widget.scrollTop = requestRow.offsetHeight + 1; + await nextFrame(); + const stickyContainer = container.querySelector('.monaco-tree-sticky-container'); + + assert.deepStrictEqual({ + sourceState, + stickyRows: container.querySelectorAll('.monaco-tree-sticky-row').length, + stickyContainerEmpty: stickyContainer?.classList.contains('empty'), + }, { + sourceState: { + attachments: 1, + requestBubbles: 0, + }, + stickyRows: 0, + stickyContainerEmpty: true, + }); + + disposables.dispose(); + }); + + test('does not create an empty sticky row when request text has no renderable parts', async () => { + const { disposables, model, container, widget } = createWidget({}, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, true); + }); + const request = model.addRequest({ text: 'hidden request text', parts: [] }, { variables: [] }, 0); + const response = Array.from({ length: 40 }, (_, index) => `response line ${index}`).join('\n\n'); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(response) }); + request.response?.complete(); + + widget.refresh(); + widget.layout(300, 500); + await waitForStableLayout(widget); + widget.scrollTop = 200; + await nextFrame(); + + const stickyContainer = container.querySelector('.monaco-tree-sticky-container'); + assert.deepStrictEqual({ + stickyRows: container.querySelectorAll('.monaco-tree-sticky-row').length, + stickyContainerEmpty: stickyContainer?.classList.contains('empty'), + }, { + stickyRows: 0, + stickyContainerEmpty: true, + }); + + disposables.dispose(); + }); + + test('removes a sticky row whose connected source has no visible geometry', async () => { + const { disposables, model, container, widget } = createWidget({}, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, true); + }); + const text = 'visible request text'; + const request = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)] + }, { variables: [] }, 0); + const response = Array.from({ length: 40 }, (_, index) => `response line ${index}`).join('\n\n'); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(response) }); + request.response?.complete(); + + widget.refresh(); + widget.layout(300, 500); + await waitForStableLayout(widget); + widget.scrollTop = 0; + await nextFrame(); + + const style = mainWindow.document.createElement('style'); + style.textContent = '.hide-connected-sticky-source .monaco-tree-sticky-row .chat-markdown-part.rendered-markdown { display: none; }'; + container.appendChild(style); + container.classList.add('hide-connected-sticky-source'); + widget.scrollTop = 200; + const stickyRowBeforeConnectedValidation = container.querySelectorAll('.monaco-tree-sticky-row').length; + await nextFrame(); + await nextFrame(); + await nextFrame(); + const stickyContainer = container.querySelector('.monaco-tree-sticky-container'); + const hiddenSourceState = { + stickyRows: container.querySelectorAll('.monaco-tree-sticky-row').length, + stickyContainerEmpty: stickyContainer?.classList.contains('empty'), + }; + + container.classList.remove('hide-connected-sticky-source'); + widget.rerender(); + await nextFrame(); + await nextFrame(); + const restoredStickyRow = container.querySelector('.monaco-tree-sticky-row'); + + assert.deepStrictEqual({ + stickyRowBeforeConnectedValidation, + hiddenSourceState, + restoredStickyVisible: restoredStickyRow?.textContent?.includes(text), + }, { + stickyRowBeforeConnectedValidation: 1, + hiddenSourceState: { + stickyRows: 0, + stickyContainerEmpty: true, + }, + restoredStickyVisible: true, + }); + + disposables.dispose(); + }); + + test('does not create an empty sticky row while editing a request to an empty value', async () => { + let editingValue = 'visible request text'; + const { disposables, model, viewModel, container, widget } = createWidget({ + getEditingValue: () => editingValue, + }, configurationService => { + configurationService.setUserConfiguration(PROMPT_TIMELINE_STICKY_SCROLL_SETTING, true); + configurationService.setUserConfiguration(ChatConfiguration.ExperimentalStickyScrollEnabled, true); + }, true); + container.classList.add('interactive-list'); + container.style.setProperty('--vscode-spacing-size80', '8px'); + const text = editingValue; + const request = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)] + }, { variables: [] }, 0); + const response = Array.from({ length: 40 }, (_, index) => `response line ${index}`).join('\n\n'); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(response) }); + request.response?.complete(); + + widget.refresh(); + widget.layout(300, 500); + await waitForStableLayout(widget); + widget.scrollTop = 200; + await nextFrame(); + const stickyBeforeEdit = container.querySelector('.monaco-tree-sticky-row')?.textContent?.includes(text); + + editingValue = ''; + viewModel.setEditing(viewModel.getItems().find(isRequestVM)!); + widget.rerender(); + await nextFrame(); + await nextFrame(); + + const stickyContainer = container.querySelector('.monaco-tree-sticky-container'); + const emptyEditState = { + stickyRows: container.querySelectorAll('.monaco-tree-sticky-row').length, + stickyContainerEmpty: stickyContainer?.classList.contains('empty'), + }; + + editingValue = 'edited request text'; + widget.rerender(); + await nextFrame(); + await nextFrame(); + const restoredStickyRow = container.querySelector('.monaco-tree-sticky-row'); + + assert.deepStrictEqual({ + stickyBeforeEdit, + emptyEditState, + restoredStickyVisible: restoredStickyRow?.textContent?.includes(editingValue), + }, { + stickyBeforeEdit: true, + emptyEditState: { + stickyRows: 0, + stickyContainerEmpty: true, + }, + restoredStickyVisible: true, + }); + + disposables.dispose(); + }); + // Regression test for the completed-response disclosure ("Completed N steps in ..."): expanding // a collapsible while the transcript is scrolled to the very bottom used to auto-scroll to the // new end, so the revealed content grew *upwards* and pushed the summary off the top of the 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 9eea6e23ca89ad..53d0edfa92d914 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 @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mainWindow } from '../../../../../../base/browser/window.js'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Emitter } from '../../../../../../base/common/event.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; 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'; @@ -14,8 +16,10 @@ import { SaveReason } from '../../../../../common/editor.js'; import { ISaveAllEditorsOptions, ISaveEditorsResult } from '../../../../../services/editor/common/editorService.js'; import { TestEditorService } from '../../../../../test/browser/workbenchTestServices.js'; import { acceptAndAwaitSentRequest, ChatWidget, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome, shouldUnlockChatPetQueueOrSteeringMessage, shouldUnlockChatPetRequestRevision } from '../../../browser/widget/chatWidget.js'; +import { IChatListItemTemplate } from '../../../browser/widget/chatListRenderer.js'; import { ChatRequestQueueKind, ChatSendResult, ChatSendResultSent, IChatSendRequestData } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../common/constants.js'; +import { IChatRequestViewModel } from '../../../common/model/chatViewModel.js'; import { ChatRequestSlashCommandPart, ChatRequestTextPart, IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; import { observePromptTimelineHostWidth } from '../../../browser/promptTimeline/promptTimelineWidgetContrib.js'; @@ -143,6 +147,52 @@ suite('ChatWidget', () => { ], [true, false]); }); + test('sticky request click survives synchronous template disposal during reveal', () => { + const request = upcastPartial({ + id: 'request', + message: upcastPartial({}), + }); + const stickyRow = mainWindow.document.createElement('div'); + stickyRow.classList.add('monaco-tree-sticky-row'); + const rowContainer = mainWindow.document.createElement('div'); + stickyRow.appendChild(rowContainer); + const stickyTemplate = upcastPartial({ currentElement: request, rowContainer }); + const realTemplate = upcastPartial({}); + let revealedRequest: IChatRequestViewModel | undefined; + let requestedTemplateId: string | undefined; + let clickedTemplate: IChatListItemTemplate | undefined; + const widget = Object.create(ChatWidget.prototype) as unknown as { + handleRequestClick(item: IChatListItemTemplate): void; + }; + Object.defineProperties(widget, { + listWidget: { + value: { + reveal: (element: IChatRequestViewModel) => { + revealedRequest = element; + stickyTemplate.currentElement = undefined; + }, + getTemplateDataForRequestId: (requestId: string) => { + requestedTemplateId = requestId; + return realTemplate; + }, + }, + }, + clickedRequest: { value: (item: IChatListItemTemplate) => clickedTemplate = item }, + }); + + widget.handleRequestClick(stickyTemplate); + + assert.deepStrictEqual({ + revealedRequest, + requestedTemplateId, + clickedTemplate, + }, { + revealedRequest: request, + requestedTemplateId: request.id, + clickedTemplate: realTemplate, + }); + }); + test('only unlocks request revision for edited user submissions', () => { assert.deepStrictEqual([ shouldUnlockChatPetRequestRevision(false, false),