mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-24 08:45:18 +01:00
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:
+202
-5
@@ -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) {
|
||||
|
||||
+11
-1
@@ -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);
|
||||
}
|
||||
|
||||
+406
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user