Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ function extractTextFromPart(content: IChatThinkingPart): string {
return raw.trim();
}

function extractActiveTextFromPart(content: IChatThinkingPart): string {
return Array.isArray(content.value)
? (content.value.findLast(value => !!value) ?? '').trim()
: extractTextFromPart(content);
}

function isEditToolId(toolId: string): boolean {
const lowerToolId = toolId.toLowerCase();
return lowerToolId.includes('edit') ||
Expand Down Expand Up @@ -372,6 +378,7 @@ export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implem

private id: string | undefined;
private content: IChatThinkingPart;
private arrayThinkingSource: IChatThinkingPart | undefined;
private currentThinkingValue: string;
private currentTitle: string;
private defaultTitle = localize('chat.thinking.header', 'Thinking');
Expand Down Expand Up @@ -1362,18 +1369,25 @@ export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implem
this.setExpanded(true);
}

public setArrayThinkingSource(content: IChatThinkingPart): void {
this.arrayThinkingSource = content;
}

public updateThinking(content: IChatThinkingPart): void {
// If disposed, ignore late updates coming from renderer diffing
if (this._store.isDisposed) {
return;
}
if (Array.isArray(content.value)) {
this.setArrayThinkingSource(content);
content = { ...content, value: extractActiveTextFromPart(content) };
}
this.content = content;
this.reasoningDurationMs = content.reasoningDurationMs;

// Update any pending lazy thinking item with matching ID so that
// when materialized, it will have the latest streaming content
// Array sections share an ID; only update the lazy item for the current text container.
for (const lazyItem of this.lazyItems) {
if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id) {
if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id && lazyItem.textContainer === this.textContainer) {
lazyItem.content = content;
break;
}
Expand Down Expand Up @@ -1582,6 +1596,9 @@ export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implem
for (const thinkingPart of this.allThinkingParts) {
thinkingPart.generatedTitle = title;
}
if (this.arrayThinkingSource) {
this.arrayThinkingSource.generatedTitle = title;
}
}

private loadTitleCache(): Record<string, { title: string; storedAt: number }> {
Expand Down Expand Up @@ -2747,7 +2764,21 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks):
return false;
}

return other?.id !== this.id;
if (other.id !== this.id) {
return true;
}

if (Array.isArray(other.value) && this.arrayThinkingSource) {
return other === this.arrayThinkingSource
&& extractActiveTextFromPart(other) === extractTextFromPart(this.content)
&& other.reasoningDurationMs === this.reasoningDurationMs
&& other.generatedTitle === this.content.generatedTitle;
}

// Accept replacement model parts so generated titles are written back to the current part.
return other === this.content
Comment thread
dmitrivMS marked this conversation as resolved.
&& extractTextFromPart(other) === this.currentThinkingValue
&& other.reasoningDurationMs === this.reasoningDurationMs;
Comment thread
dmitrivMS marked this conversation as resolved.
}

override dispose(): void {
Expand Down
12 changes: 7 additions & 5 deletions src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2630,7 +2630,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch

const contentForThisTurn = this.getNextProgressiveRenderContent(element, templateData);
const partsToRender = this.diff(templateData.renderedParts ?? [], contentForThisTurn.content, element);
const contentIsAlreadyRendered = partsToRender.every(part => part === null);
const contentIsAlreadyRendered = partsToRender.length === (templateData.renderedParts?.length ?? 0) && partsToRender.every(part => part === null);
if (!contentIsAlreadyRendered) {
this.renderChatContentDiff(partsToRender, contentForThisTurn.content, element, index, templateData);
} else {
Expand Down Expand Up @@ -2723,7 +2723,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
const contentForThisTurn = this.getNextProgressiveRenderContent(element, templateData);
const partsToRender = this.diff(templateData.renderedParts ?? [], contentForThisTurn.content, element);

const contentIsAlreadyRendered = partsToRender.every(part => part === null);
const contentIsAlreadyRendered = partsToRender.length === (templateData.renderedParts?.length ?? 0) && partsToRender.every(part => part === null);
if (contentIsAlreadyRendered) {
if (!element.isComplete) {
this.remountRenderedParts(templateData);
Expand Down Expand Up @@ -2818,9 +2818,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
? alreadyRenderedPart : undefined;
if (alreadyRenderedPart) {
if (!rebuildThinkingGroup && partToRender.kind === 'thinking' && alreadyRenderedPart instanceof ChatThinkingContentPart) {
if (!Array.isArray(partToRender.value)) {
alreadyRenderedPart.updateThinking(partToRender);
}
alreadyRenderedPart.updateThinking(partToRender);
renderedParts[contentIndex] = alreadyRenderedPart;
return;
} else if (!rebuildThinkingGroup && alreadyRenderedPart instanceof ChatThinkingContentPart && this.shouldPinPart(partToRender, element)) {
Expand Down Expand Up @@ -3000,6 +2998,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
delete renderedParts[i];
}
}
renderedParts.length = partsToRender.length;

const animateCollapse = templateData.wasResponseComplete === false && element.isComplete;
this.updateCompletedResponseDisclosure(element, contentForThisTurn, templateData, animateCollapse);
Expand Down Expand Up @@ -4981,6 +4980,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}
}
}
if (lastPart instanceof ChatThinkingContentPart) {
lastPart.setArrayThinkingSource(content);
}
return lastPart ?? this.renderNoContent(other => content.kind === other.kind);
// non-array, handle case where we are currently thinking vs. starting a new thinking part
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2204,7 +2204,162 @@ suite('ChatThinkingContentPart', () => {
assert.strictEqual(result, true, 'Should accept markdown content as same content');
});

test('should return false for different thinking part with same id', () => {
test('should return true for unchanged thinking content', () => {
const values: IChatThinkingPart['value'][] = ['**Working**', ' \n**Working** \n', '', undefined, ['**Working**', ' on it']];
const context = createMockRenderContext(false);
const results = values.map(value => {
const content: IChatThinkingPart = { kind: 'thinking', value, id: 'id-1' };
const part = store.add(instantiationService.createInstance(
ChatThinkingContentPart,
content,
context,
mockMarkdownRenderer,
false
));

return part.hasSameContent(content, [], context.element);
});

assert.deepStrictEqual(results, [true, true, true, true, true]);
});

test('should detect in-place text and duration changes', () => {
const content = createThinkingPart('**Working**', 'id-1');
const context = createMockRenderContext(false);
const part = store.add(instantiationService.createInstance(
ChatThinkingContentPart,
content,
context,
mockMarkdownRenderer,
false
));

content.value = '**Updated thinking**';
const sameBeforeTextUpdate = part.hasSameContent(content, [], context.element);
part.updateThinking(content);
const sameAfterTextUpdate = part.hasSameContent(content, [], context.element);

content.reasoningDurationMs = 2300;
const sameBeforeDurationUpdate = part.hasSameContent(content, [], context.element);
part.updateThinking(content);
const sameAfterDurationUpdate = part.hasSameContent(content, [], context.element);
part.finalizeTitleIfDefault();

assert.deepStrictEqual({
sameBeforeTextUpdate,
sameAfterTextUpdate,
sameBeforeDurationUpdate,
sameAfterDurationUpdate,
finalLabel: part.domNode.querySelector('.monaco-button')?.textContent,
}, {
sameBeforeTextUpdate: false,
sameAfterTextUpdate: true,
sameBeforeDurationUpdate: false,
sameAfterDurationUpdate: true,
finalLabel: 'Updated thinking - 3s',
});
});

test('should accept replacement model parts before skipping unchanged content', () => {
const content = createThinkingPart('**Working**', 'id-1');
const context = createMockRenderContext(false);
const part = store.add(instantiationService.createInstance(
ChatThinkingContentPart,
content,
context,
mockMarkdownRenderer,
false
));
const replacement: IChatThinkingPart = {
...content,
value: '**Working** ',
metadata: { signature: 'updated' },
generatedTitle: 'Reviewed the implementation',
};

const sameBeforeUpdate = part.hasSameContent(replacement, [], context.element);
part.updateThinking(replacement);
const sameAfterUpdate = part.hasSameContent(replacement, [], context.element);
part.finalizeTitleIfDefault();

assert.deepStrictEqual({
sameBeforeUpdate,
sameAfterUpdate,
finalLabel: part.domNode.querySelector('.monaco-button')?.textContent,
}, {
sameBeforeUpdate: false,
sameAfterUpdate: true,
finalLabel: 'Reviewed the implementation',
});
});

test('should persist generated titles on replacement model parts with unchanged text', () => {
const content = createThinkingPart('**Working**', 'id-1');
const context = createMockRenderContext(false);
const part = store.add(instantiationService.createInstance(
ChatThinkingContentPart,
content,
context,
mockMarkdownRenderer,
false
));
const replacement: IChatThinkingPart = { ...content, value: '**Working** ' };
const sameBeforeUpdate = part.hasSameContent(replacement, [], context.element);
if (!sameBeforeUpdate) {
part.updateThinking(replacement);
}
part.finalizeTitleIfDefault();

assert.deepStrictEqual({
sameBeforeUpdate,
generatedTitle: replacement.generatedTitle,
}, {
sameBeforeUpdate: false,
generatedTitle: 'Working',
});
});

for (const thinkingStyle of [ThinkingDisplayMode.Collapsed, ThinkingDisplayMode.CollapsedPreview, ThinkingDisplayMode.FixedScrolling]) {
test(`should compare only the active grouped thinking section in ${thinkingStyle} mode`, () => {
mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', thinkingStyle);
const content = createThinkingPart('**Earlier thinking**', 'id-1');
const context = createMockRenderContext(false);
const part = store.add(instantiationService.createInstance(
ChatThinkingContentPart,
content,
context,
mockMarkdownRenderer,
false
));
const nextContent = createThinkingPart('**Current thinking**', 'id-2');
part.setupThinkingContainer(nextContent);
part.updateThinking(nextContent);
const sameEarlierSection = part.hasSameContent(content, [], context.element);
const sameActiveSection = part.hasSameContent(nextContent, [], context.element);

nextContent.value += ' with more detail';
const sameBeforeUpdate = part.hasSameContent(nextContent, [], context.element);
part.updateThinking(nextContent);
const sameAfterUpdate = part.hasSameContent(nextContent, [], context.element);
part.resetId();

assert.deepStrictEqual({
sameEarlierSection,
sameActiveSection,
sameBeforeUpdate,
sameAfterUpdate,
sameInactiveSection: part.hasSameContent(nextContent, [], context.element),
}, {
sameEarlierSection: true,
sameActiveSection: true,
sameBeforeUpdate: false,
sameAfterUpdate: true,
sameInactiveSection: true,
});
});
}

test('should return false for changed thinking text with the same id', () => {
const content = createThinkingPart('**Working**', 'id-1');
const context = createMockRenderContext(false);

Expand All @@ -2218,9 +2373,8 @@ suite('ChatThinkingContentPart', () => {

const otherThinking: IChatRendererContent = createThinkingPart('**Different**', 'id-1');

// When the id is the same, hasSameContent returns true (other.id !== this.id is false)
const result = part.hasSameContent(otherThinking, [], context.element);
assert.strictEqual(result, false, 'Should return false for thinking part with same id');
assert.strictEqual(result, false, 'Should update changed thinking text');
});

test('should return true for thinking part with different id', () => {
Expand Down
Loading
Loading