diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 1f015b075f8..9bcc5099799 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -174,6 +174,56 @@ function extractTitleFromThinkingContent(content: string): string | undefined { return headerMatch ? headerMatch[1] : undefined; } +/** A line that is entirely a bold span, e.g. `**Analyzing the request**`. */ +function isThinkingHeaderLine(line: string): boolean { + return /^\s*\*\*.+\*\*\s*$/.test(line); +} + +/** Strips the surrounding `**` when the whole text is a single bold span, so a standalone header renders as plain text. */ +function stripStandaloneBold(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith('**') && trimmed.indexOf('**', 2) === trimmed.length - 2) { + return trimmed.slice(2, -2); + } + return text; +} + +/** + * Splits a reasoning-summary value into one markdown string per display row. + * Rows are delimited by bold header lines. When {@link dropLeadingHeader} is set + * and the value starts with a header, that header is dropped because it is + * surfaced as the collapsible title. Returns `undefined` unless the value has at + * least two header lines, so ordinary reasoning prose keeps single-block rendering. + */ +export function splitReasoningSummaryRows(text: string, dropLeadingHeader = true): string[] | undefined { + const sections: { isHeader: boolean; lines: string[] }[] = []; + for (const line of text.split('\n')) { + if (isThinkingHeaderLine(line)) { + sections.push({ isHeader: true, lines: [line] }); + } else if (sections.length === 0) { + sections.push({ isHeader: false, lines: [line] }); + } else { + sections[sections.length - 1].lines.push(line); + } + } + + if (sections.filter(section => section.isHeader).length < 2) { + return undefined; + } + + const dropFirst = dropLeadingHeader && sections[0].isHeader; + const rows: string[] = []; + sections.forEach((section, index) => { + const lines = index === 0 && dropFirst ? section.lines.slice(1) : section.lines; + const markdown = lines.join('\n').trim(); + if (markdown) { + rows.push(markdown); + } + }); + + return rows.length ? rows : undefined; +} + type ChatThinkingTitle = string | IMarkdownString; function getThinkingTitleValue(title: ChatThinkingTitle): string { @@ -333,6 +383,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private readonly workingTitle = localize('chat.thinking.header.working', 'Working'); private textContainer!: HTMLElement; private readonly _markdownResult = this._register(new MutableDisposable()); + private summaryRowItems: HTMLElement[] = []; + private summaryRowResults: (IRenderedMarkdown | undefined)[] = []; + private summaryRowTexts: string[] = []; + private droppedSummaryHeader: string | undefined; + private readonly retiredSummaryRowResults: IRenderedMarkdown[] = []; private wrapper!: HTMLElement; private fixedScrollingMode: boolean = false; private readonly thinkingDisplayMode: ThinkingDisplayMode; @@ -450,6 +505,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.extractedTitles.push(extractedTitle); } this.currentThinkingValue = initialText; + this.trackDroppedSummaryHeader(initialText); if (initialText.trim()) { this.appendedItemCount++; @@ -510,6 +566,15 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.ownedToolParts.clear(); })); + this._register(toDisposable(() => { + for (const result of this.summaryRowResults) { + result?.dispose(); + } + for (const result of this.retiredSummaryRowResults) { + result.dispose(); + } + })); + // override for codicon chevron in the collapsible part this._register(autorun(r => { const isExpanded = this.expanded.read(r); @@ -888,20 +953,40 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this._store.isDisposed) { return; } + + // A later thinking part reassigns textContainer; retire stale row tracking + // so the predecessor's rendered rows stay frozen while this part renders. + if (this.summaryRowItems.length && this.summaryRowItems[0] !== this.textContainer) { + this.retireSummaryRows(); + } + const cleanedContent = content.trim(); if (!cleanedContent) { this._markdownResult.clear(); + this.clearSummaryRows(); if (this.textContainer) { clearNode(this.textContainer); } return; } - // If the entire content is bolded, strip the bold markers for rendering - let contentToRender = cleanedContent; - if (cleanedContent.startsWith('**') && cleanedContent.endsWith('**')) { - contentToRender = cleanedContent.slice(2, -2); + // Multi-header reasoning summaries render each header section as its own + // row so the dropdown reads as a list. Fixed-scrolling keeps its single + // auto-scrolling block. Sibling rows need an attached container so their + // insertion isn't a no-op, so a detached (lazy) container falls through to + // single-block rendering until it is materialized. A block drops its leading + // header only when that header is the tracked title owner, so a grouped block + // never drops a header that isn't surfaced as the title. + const dropLeadingHeader = this.droppedSummaryHeader !== undefined && extractTitleFromThinkingContent(cleanedContent) === this.droppedSummaryHeader; + const summaryRows = this.fixedScrollingMode ? undefined : splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); + if (summaryRows && this.textContainer?.parentNode) { + this.renderSummaryRows(summaryRows); + return; } + this.clearSummaryRows(); + + // If the entire content is bolded, strip the bold markers for rendering + const contentToRender = stripStandaloneBold(cleanedContent); const target = reuseExisting ? this._markdownResult.value?.element : undefined; @@ -920,6 +1005,103 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } + /** Renders one summary row, reusing the row's element while its text only grows. */ + private renderSummaryRow(container: HTMLElement, index: number, markdown: string): void { + const previous = this.summaryRowResults[index]; + const reuse = !!previous && markdown.startsWith(this.summaryRowTexts[index] ?? ''); + // A standalone header renders as plain text, not bold. + const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(stripStandaloneBold(markdown)), { + fillInIncompleteTokens: true, + asyncRenderCallback: this._asyncRenderCallback, + codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, + }, reuse ? previous?.element : undefined); + if (!reuse) { + clearNode(container); + container.appendChild(createThinkingIcon(Codicon.circleFilled)); + container.appendChild(rendered.element); + } + previous?.dispose(); + this.summaryRowResults[index] = rendered; + this.summaryRowTexts[index] = markdown; + } + + private renderSummaryRows(rows: string[]): void { + // Rows own the DOM in this mode; release the single-block renderer. + this._markdownResult.clear(); + + for (let i = 0; i < rows.length; i++) { + let container = this.summaryRowItems[i]; + if (!container) { + container = i === 0 ? this.textContainer : $('.chat-thinking-item.markdown-content'); + this.summaryRowItems[i] = container; + this.summaryRowTexts[i] = ''; + if (i === 0) { + clearNode(container); + } else { + this.summaryRowItems[i - 1].after(container); + } + } + if (this.summaryRowTexts[i] !== rows[i]) { + this.renderSummaryRow(container, i, rows[i]); + } + } + + // Streaming only appends, but guard against a shrinking row set on re-render. + for (let i = this.summaryRowItems.length - 1; i >= rows.length; i--) { + this.summaryRowResults[i]?.dispose(); + if (this.summaryRowItems[i] !== this.textContainer) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems.length = rows.length; + this.summaryRowResults.length = rows.length; + this.summaryRowTexts.length = rows.length; + } + + /** Removes the extra summary rows and resets tracking, keeping the text container. */ + private clearSummaryRows(): void { + if (!this.summaryRowItems.length) { + return; + } + for (let i = 0; i < this.summaryRowItems.length; i++) { + this.summaryRowResults[i]?.dispose(); + if (i !== 0) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** Keeps a prior part's rendered rows in the DOM; defers disposal to teardown. */ + private retireSummaryRows(): void { + for (const result of this.summaryRowResults) { + if (result) { + this.retiredSummaryRowResults.push(result); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** + * Records the leading header the primary summary block drops, derived from content + * so it is available at finalize even when the rows never lazily rendered (the + * collapsed-through-completion flow). First-writer wins: the first grouped block + * that is a multi-header summary owns the title, and only that header is dropped. + */ + private trackDroppedSummaryHeader(value: string): void { + if (this.droppedSummaryHeader) { + return; + } + const trimmed = value.trim(); + if (!this.fixedScrollingMode && splitReasoningSummaryRows(trimmed, true)) { + this.droppedSummaryHeader = extractTitleFromThinkingContent(trimmed); + } + } + private setFinalizedTitle(title: string): void { if (!this._collapseButton) { return; @@ -1218,6 +1400,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const previousValue = this.currentThinkingValue; const reuseExisting = !!(this._markdownResult.value && next.startsWith(previousValue) && next.length > previousValue.length); this.currentThinkingValue = next; + this.trackDroppedSummaryHeader(next); this.renderMarkdown(next, reuseExisting); if (this.fixedScrollingMode && this.scrollableElement) { @@ -1315,6 +1498,15 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.updateDropdownClickability(); + // A leading summary header removed from the rows must remain the title, even when a restored generated title exists. + if (this.droppedSummaryHeader) { + this.currentTitle = this.droppedSummaryHeader; + this.content.generatedTitle = this.droppedSummaryHeader; + this.setGeneratedTitleOnAllParts(this.droppedSummaryHeader); + this.setFinalizedTitle(this.droppedSummaryHeader); + return; + } + if (this.content.generatedTitle) { this.currentTitle = this.content.generatedTitle; this.setGeneratedTitleOnAllParts(this.content.generatedTitle); @@ -2420,7 +2612,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } this.appendedItemCount++; this.allThinkingParts.push(content); - this.recordReasoningContent(extractTextFromPart(content)); + const contentText = extractTextFromPart(content); + this.recordReasoningContent(contentText); + // First-writer wins: a later grouped block can be the first multi-header + // summary (when earlier blocks had <2 headers), so track it here too — the + // lazy/reload path never routes through updateThinking. + this.trackDroppedSummaryHeader(contentText); this.textContainer = $('.chat-thinking-item.markdown-content'); // Observe the new textContainer for child resizes in fixed scrolling mode if (this.childResizeObserver && this.fixedScrollingMode && !this.streamingCompleted) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css index ff2c204ebae..9ac96db798c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css @@ -524,6 +524,8 @@ } .chat-tool-invocation-part { + line-height: 1.5em; + .chat-confirmation-widget { border: none; font-size: var(--vscode-chat-font-size-body-s); @@ -541,12 +543,20 @@ padding: 2px 6px 2px 0px; &.monaco-button { - width: fit-content; outline: none; gap: 4px; } + &.monaco-text-button { + font-size: inherit; + font-family: inherit; + line-height: inherit; + padding-bottom: 0; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; + } + .codicon { font-size: var(--vscode-codiconFontSize-compact); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index ff2c20aad7a..240db5ac402 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -17,7 +17,7 @@ import { IEditorService } from '../../../../../../services/editor/common/editorS import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; -import { ChatThinkingContentPart, getToolInvocationIcon, maybePickFunWorkingMessage } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; +import { ChatThinkingContentPart, getToolInvocationIcon, maybePickFunWorkingMessage, splitReasoningSummaryRows } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; import { IChatExternalEdit, IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IChatContentPartDiffData, IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; @@ -645,6 +645,39 @@ suite('ChatThinkingContentPart', () => { assert.ok(thinkingItem, 'Should have thinking item'); }); + test('re-splits a summary into rows as later headers stream in', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const firstSummary = '**Refactoring session policy and cleanup**'; + const content = createThinkingPart(firstSummary); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector('.monaco-button')?.click(); + + part.updateThinking(createThinkingPart( + `${firstSummary}\n\n**Updating session token handling**`, + content.id + )); + + const rows = Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content')); + assert.deepStrictEqual({ + rowTexts: rows.map(row => row.textContent?.trim()), + hasLiteralMarkers: part.domNode.textContent?.includes('**') ?? false, + }, { + rowTexts: ['Updating session token handling'], + hasLiteralMarkers: false, + }); + }); + test('should track multiple title extractions', () => { const content = createThinkingPart('**First title**'); const context = createMockRenderContext(false); @@ -696,6 +729,378 @@ suite('ChatThinkingContentPart', () => { }); }); + suite('Reasoning summary rows', () => { + setup(() => { + mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed); + }); + + function expandedSummaryRows(value: string): HTMLElement[] { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + createThinkingPart(value), + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector('.monaco-button')?.click(); + return Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content')); + } + + test('splitReasoningSummaryRows parses headers, bodies, and prose', () => { + assert.deepStrictEqual({ + headersOnly: splitReasoningSummaryRows('**H1**\n\n**H2**\n\n**H3**'), + headerBodies: splitReasoningSummaryRows('**H1**\n\nbody1\n\n**H2**\n\nbody2'), + twoHeadersNoBody: splitReasoningSummaryRows('**H1**\n\n**H2**'), + leadingProse: splitReasoningSummaryRows('intro\n\n**H1**\n\n**H2**'), + keepLeadingHeader: splitReasoningSummaryRows('**H1**\n\n**H2**', false), + singleHeader: splitReasoningSummaryRows('**Only header**'), + prose: splitReasoningSummaryRows('Just thinking about the problem.'), + }, { + headersOnly: ['**H2**', '**H3**'], + headerBodies: ['body1', '**H2**\n\nbody2'], + twoHeadersNoBody: ['**H2**'], + leadingProse: ['intro', '**H1**', '**H2**'], + keepLeadingHeader: ['**H1**', '**H2**'], + singleHeader: undefined, + prose: undefined, + }); + }); + + test('keeps a later grouped block\'s leading header so no header is lost', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + createThinkingPart('**Reviewing the plan**\n\n**Weighing tradeoffs**'), + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector('.monaco-button')?.click(); + + part.setupThinkingContainer(createThinkingPart('**Editing files**\n\n**Verifying the change**', 'block-2')); + + const rowTexts = Array.from( + part.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), + row => row.textContent?.trim() + ); + assert.deepStrictEqual({ + rowTexts, + hasLiteralMarkers: part.domNode.textContent?.includes('**') ?? false, + }, { + // First block drops its title header ("Reviewing the plan"); the grouped + // block keeps both of its headers so neither is lost. + rowTexts: ['Weighing tradeoffs', 'Editing files', 'Verifying the change'], + hasLiteralMarkers: false, + }); + }); + + test('renders each summary header as its own row and drops the leading header', () => { + const rows = expandedSummaryRows([ + '**Analyzing bold syntax with spaces**', + '**Examining special stripping logic**', + '**Explaining Markdown bold whitespace nuances**', + ].join('\n\n')); + + assert.deepStrictEqual({ + rowTexts: rows.map(row => row.textContent?.trim()), + hasLiteralMarkers: rows.some(row => row.textContent?.includes('**')), + anyRowBold: rows.some(row => !!row.querySelector('strong')), + }, { + rowTexts: ['Examining special stripping logic', 'Explaining Markdown bold whitespace nuances'], + hasLiteralMarkers: false, + anyRowBold: false, + }); + }); + + test('keeps a section body attached to its header row', () => { + const rows = expandedSummaryRows('**Reviewing the plan**\n\nWeigh the tradeoffs\n\n**Applying the change**\n\nEdit the file'); + + assert.deepStrictEqual(rows.map(row => ({ + strong: Array.from(row.querySelectorAll('strong'), element => element.textContent), + text: row.textContent?.replace(/\s+/g, ' ').trim(), + })), [ + { strong: [], text: 'Weigh the tradeoffs' }, + { strong: ['Applying the change'], text: 'Applying the changeEdit the file' }, + ]); + }); + + test('renders a single-header summary as one block', () => { + const rows = expandedSummaryRows('**Working on it**'); + + assert.deepStrictEqual({ + rowCount: rows.length, + text: rows[0]?.textContent?.trim(), + hasStrong: !!rows[0]?.querySelector('strong'), + }, { + rowCount: 1, + text: 'Working on it', + hasStrong: false, + }); + }); + + test('surfaces the dropped leading header as the finalized title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Reviewing the plan**'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector('.monaco-button')?.click(); + + part.updateThinking(createThinkingPart('**Reviewing the plan**\n\n**Weighing tradeoffs**\n\n**Applying the change**', content.id)); + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.deepStrictEqual({ + title: titleButton?.textContent?.trim(), + rows: Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + }, { + title: 'Reviewing the plan', + rows: ['Weighing tradeoffs', 'Applying the change'], + }); + }); + + test('keeps the dropped header as the title over a restored content title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Reviewing the plan**\n\n**Applying the change**'); + content.generatedTitle = 'Reviewed implementation details'; + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(true), + markdownRenderer, + true + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + part.finalizeTitleIfDefault(); + part.domNode.querySelector('.monaco-button')?.click(); + + assert.deepStrictEqual({ + title: part.domNode.querySelector('.chat-used-context-label .monaco-button')?.textContent?.trim(), + rows: Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + generatedTitle: content.generatedTitle, + }, { + title: 'Reviewing the plan', + rows: ['Applying the change'], + generatedTitle: 'Reviewing the plan', + }); + }); + + test('keeps the dropped header as the title when a titled tool joins the group', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Analyzing the request**'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector('.monaco-button')?.click(); + + part.updateThinking(createThinkingPart('**Analyzing the request**\n\n**Planning the edits**', content.id)); + + const toolInvocation = { + kind: 'toolInvocation', + toolId: 'edit', + toolCallId: 'call-1', + invocationMessage: 'Editing file.ts', + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + source: ToolDataSource.Internal, + isAttachedToThinking: false, + generatedTitle: 'Edited implementation details', + state: observableValue('state', { + type: IChatToolInvocation.StateKind.Executing, + confirmed: { type: 0 }, + progress: observableValue('progress', { progress: 0 }), + parameters: {}, + confirmationMessages: undefined, + }), + toolSpecificDataKind: observableValue('tool', undefined), + toJSON: () => ({} as IChatToolInvocationSerialized), + } as unknown as IChatToolInvocation; + part.appendItem(() => ({ domNode: $('div.test-tool-item') }), toolInvocation.toolId, toolInvocation); + + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.deepStrictEqual({ + title: titleButton?.textContent?.trim(), + summaryRows: Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + }, { + title: 'Analyzing the request', + summaryRows: ['Planning the edits'], + }); + }); + + test('keeps the dropped header as the title over a cached title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const context = createMockRenderContext(true); + const thinkingId = 'restored-summary-part'; + const cacheKey = `${chatSessionResourceToId(context.element.sessionResource)}:${thinkingId}`; + instantiationService.get(IStorageService).store( + 'chat.thinkingTitleCache', + JSON.stringify({ [cacheKey]: { title: 'Reviewed implementation details', storedAt: Date.now() } }), + StorageScope.PROFILE, + StorageTarget.MACHINE + ); + const content = createThinkingPart('**Analyzing the request**\n\n**Verifying the result**', thinkingId); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + markdownRenderer, + true + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + part.finalizeTitleIfDefault(); + part.domNode.querySelector('.monaco-button')?.click(); + + assert.deepStrictEqual({ + title: part.domNode.querySelector('.chat-used-context-label .monaco-button')?.textContent?.trim(), + rows: Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()), + generatedTitle: content.generatedTitle, + }, { + title: 'Analyzing the request', + rows: ['Verifying the result'], + generatedTitle: 'Analyzing the request', + }); + }); + + test('surfaces the dropped header as the title when collapsed through completion', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart('**Analyzing the request**'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + // Stays collapsed (rows never lazily render) through streaming and a tool call. + part.updateThinking(createThinkingPart('**Analyzing the request**\n\n**Planning the edits**', content.id)); + const toolInvocation = { + kind: 'toolInvocation', + toolId: 'edit', + toolCallId: 'call-1', + invocationMessage: 'Editing file.ts', + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + source: ToolDataSource.Internal, + isAttachedToThinking: false, + generatedTitle: undefined, + state: observableValue('state', { + type: IChatToolInvocation.StateKind.Executing, + confirmed: { type: 0 }, + progress: observableValue('progress', { progress: 0 }), + parameters: {}, + confirmationMessages: undefined, + }), + toolSpecificDataKind: observableValue('tool', undefined), + toJSON: () => ({} as IChatToolInvocationSerialized), + } as unknown as IChatToolInvocation; + part.appendItem(() => ({ domNode: $('div.test-tool-item') }), toolInvocation.toolId, toolInvocation); + + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.strictEqual(titleButton?.textContent?.trim(), 'Analyzing the request'); + }); + + test('tracks the dropped header off a later grouped block when the first has one header', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + // The first block is a single-header block (renders as one block, drops nothing). + const content = createThinkingPart('**Reading the file**', 'block-0'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + // A later grouped block is the first multi-header summary; collapsed through completion. + part.setupThinkingContainer(createThinkingPart('**Analyzing the request**\n\n**Planning the edits**', 'block-1')); + part.finalizeTitleIfDefault(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.strictEqual(titleButton?.textContent?.trim(), 'Analyzing the request'); + }); + + test('does not drop a grouped block header that is not the tracked title', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + // Two multi-header summary blocks grouped, collapsed through completion. + const content = createThinkingPart('**Analyzing the request**\n\n**Reviewing constraints**', 'block-0'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + part.setupThinkingContainer(createThinkingPart('**Editing files**\n\n**Verifying output**', 'block-1')); + part.finalizeTitleIfDefault(); + // Expand afterwards to materialize the lazy blocks. + part.domNode.querySelector('.monaco-button')?.click(); + + const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + const rows = Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content'), row => row.textContent?.trim()); + assert.deepStrictEqual({ + title: titleButton?.textContent?.trim(), + // The later block's leading header is not the tracked title, so it is kept as a row. + keepsLaterHeader: rows.includes('Editing files'), + }, { + title: 'Analyzing the request', + keepsLaterHeader: true, + }); + }); + }); + suite('Thinking group identity', () => { setup(() => { mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed);