reasoning ux: fix tools jumping + split reasoning summary headers (#331844)

* reasoning ux: fix tools jumping + split reasoning summary headers

* fix jump

* fix reasoning ux
This commit is contained in:
Justin Chen
2026-08-21 00:25:07 +00:00
committed by GitHub
parent 33e1911ac9
commit 0331adc16e
3 changed files with 619 additions and 7 deletions
@@ -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<IRenderedMarkdown>());
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) {
@@ -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);
}
@@ -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<HTMLElement>('.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<HTMLElement>('.monaco-button')?.click();
return Array.from(part.domNode.querySelectorAll<HTMLElement>('.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<HTMLElement>('.monaco-button')?.click();
part.setupThinkingContainer(createThinkingPart('**Editing files**\n\n**Verifying the change**', 'block-2'));
const rowTexts = Array.from(
part.domNode.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.monaco-button')?.click();
assert.deepStrictEqual({
title: part.domNode.querySelector('.chat-used-context-label .monaco-button')?.textContent?.trim(),
rows: Array.from(part.domNode.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.monaco-button')?.click();
assert.deepStrictEqual({
title: part.domNode.querySelector('.chat-used-context-label .monaco-button')?.textContent?.trim(),
rows: Array.from(part.domNode.querySelectorAll<HTMLElement>('.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<HTMLElement>('.monaco-button')?.click();
const titleButton = part.domNode.querySelector('.chat-used-context-label .monaco-button');
const rows = Array.from(part.domNode.querySelectorAll<HTMLElement>('.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);