chat: completed response disclosure (#327923)

* chat: completed response disclosure

* address feedback + fix
This commit is contained in:
Justin Chen
2026-07-29 03:52:43 +00:00
committed by GitHub
parent 632430bbe6
commit 8fdf719d28
16 changed files with 525 additions and 26 deletions
+5
View File
@@ -38,6 +38,11 @@ Then read the relevant spec for the area you are changing (see table below). If
- **Missing entry point import**: New contribution files must be imported in the appropriate `sessions.*.main.ts` entry point to be loaded (for example `sessions.common.main.ts`, `sessions.desktop.main.ts`, `sessions.web.main.ts`, or `sessions.web.main.internal.ts`).
- **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components.
- **Do not repeat subagent identity beside the open-chat pill**: The subagent pill's title is the complete inline affordance. Do not render the agent name or a generic "Subagent" phrase before it; that duplicates identity and adds visual noise.
- **Subagent model metadata is differential**: Show the subagent's model by default and hide it only when it concretely matches the parent chat's selected model. Compare both canonical model ids and registered display names; if the parent model is unresolved, still show the metadata (a match cannot be established, so surface the model rather than hiding it).
- **Optional metadata owns its separator**: Keep separators such as `·` on the conditional metadata element and create that element hidden. An empty optional label must never leave punctuation behind while its reactive visibility is still resolving.
- **Live numeric labels must not jitter**: Apply `font-variant-numeric: tabular-nums` with `font-feature-settings: "tnum"` as a fallback to elapsed-time and other numeric labels that update in place.
- **Collapsed work summaries must quantify what they hide**: Prefer outcome-oriented copy such as "Completed 6 steps in 2m" over vague elapsed-only text such as "Worked for a few minutes." Count the visible items placed inside the disclosure so the summary matches what expanding it reveals.
- **Custom action proxies must propagate owner-observed state**: When an action view wraps a menu action in a proxy, state that controls surrounding UI (such as enabled/available) must also be written to the original menu action observed by the owner. Re-subscribe on `IActionViewItemService.onDidChange` for late factory registration, but do not assume the replacement proxy's state automatically reaches the menu action.
- **Editor feedback glyph placement**: Use Monaco's `lineNumberClassName` when the feedback affordance should replace the number only while its line is hovered; it eliminates a dedicated glyph lane while preserving the number at rest. Style the line-number pseudo-element as the full feedback control, including its themed hover background, so its visual and click target match.
- **Line-number decoration tooltips belong in Monaco decoration options**: A `lineNumberClassName` node is regenerated as the editor renders and scrolls, so DOM-managed hovers can silently attach to a stale or never-decorated element. Set the localized `lineNumberHoverMessage` with the same decoration instead; Monaco's glyph hover controller follows the rendered line-number lifecycle.
- **Compact multi-diff control alignment**: The file-header twistie, unchanged-region expand control, and fold control form one visual column in the Agents editor. Remove the header content's left padding and use the same small inset for both unchanged-region controls; do not let the shared multi-diff defaults leave each control at a separate horizontal offset.
@@ -494,6 +494,7 @@
border-radius: 3px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
/* The themed border below replaces the browser's native focus outline. */
outline: none;
}
@@ -505,6 +506,7 @@
color: var(--vscode-input-placeholderForeground);
}
/* Filter action buttons remain quiet until the pointer reveals them. */
.action-widget .action-list-filter-actions .action-label {
padding: 3px;
border-radius: 3px;
File diff suppressed because one or more lines are too long
@@ -287,6 +287,8 @@
color: var(--vscode-descriptionForeground);
font-size: var(--vscode-agents-fontSize-label2);
font-style: italic;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
opacity: 0.7;
pointer-events: none;
}
@@ -13,6 +13,7 @@ import { Emitter } from '../../../../../base/common/event.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { autorun, IReader } from '../../../../../base/common/observable.js';
import { isEqual } from '../../../../../base/common/resources.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { localize, localize2 } from '../../../../../nls.js';
@@ -26,6 +27,7 @@ import { parseChatUri, parseSubagentSessionUri } from '../../../../../platform/a
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';
import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID } from '../../../../../workbench/contrib/chat/common/constants.js';
import { formatElapsedTime } from '../../../../../workbench/contrib/chat/common/chatProgressFormatting.js';
import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
import { renderFileWidgets } from '../../../../../workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.js';
import { IChatMarkdownAnchorService } from '../../../../../workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownAnchorService.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
@@ -157,6 +159,16 @@ function contextConfirmationCount(context: unknown): number {
return typeof count === 'number' && count > 0 ? count : 0;
}
export function shouldShowSubagentModel(subagentModelName: string | undefined, parentModelId: string | undefined, parentModelName: string | undefined, parentModelMetadataId: string | undefined): boolean {
if (!subagentModelName) {
return false;
}
const normalizedSubagentModel = subagentModelName.trim().toLowerCase();
const parentModelIdSuffix = parentModelId?.slice(parentModelId.lastIndexOf(':') + 1);
return ![parentModelId, parentModelIdSuffix, parentModelName, parentModelMetadataId]
.some(candidate => candidate?.trim().toLowerCase() === normalizedSubagentModel);
}
function createOpenSubagentAction(action: IAction): Action {
const proxy = new Action(action.id, action.label, action.class, false, context => action.run(context));
proxy.tooltip = action.tooltip;
@@ -207,6 +219,7 @@ registerAction2(OpenSubagentChatAction);
*/
export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
private readonly _sourceAction: IAction;
private _resolvedTitle: string | undefined;
private _confirmationCount = 0;
private _confirmationActive = false;
@@ -227,6 +240,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
private _durationElement: HTMLElement | undefined;
private _startedAt: number | undefined;
private _endedAt: number | undefined;
private _reportedModelName: string | undefined;
private _modelName: string | undefined;
private _displayedToolLabel: string | undefined;
private _displayedToolIcon: ThemeIcon | undefined;
@@ -244,8 +258,10 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService,
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
) {
super(context, createOpenSubagentAction(action), options);
this._sourceAction = action;
if (this._action instanceof Action) {
this._register(this._action);
}
@@ -254,6 +270,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
this._finishToolTransition();
}
}));
this._register(this.languageModelsService.onDidChangeLanguageModels(() => this._updateTitleTracker()));
}
override render(container: HTMLElement): void {
@@ -268,7 +285,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
this._iconElement = $('span.chat-subagent-pill-icon');
this._iconElement.appendChild($(`span.chat-subagent-pill-open-icon${ThemeIcon.asCSSSelector(Codicon.commentDiscussion)}`));
this._labelElement = $('span.chat-subagent-pill-label');
this._modelElement = $('span.chat-subagent-pill-model');
this._modelElement = $('span.chat-subagent-pill-model.hidden');
this._confirmationCountElement = $('span.chat-subagent-pill-confirmation-count');
const pillContent = $('span.chat-subagent-pill-content');
const pillHeader = $('span.chat-subagent-pill-header');
@@ -304,14 +321,8 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
private _updateMetadata(): void {
const context = this._context && typeof this._context === 'object' ? this._context as IOpenSubagentChatContext : undefined;
this._modelName = context?.modelName;
if (this._modelElement) {
this._modelElement.textContent = this._modelName ?? '';
this._modelElement.classList.toggle('hidden', !this._modelName);
}
this._reportedModelName = context?.modelName;
this._setActiveTool(context?.activeToolLabel, context?.activeToolIcon);
this.updateTooltip();
this.updateAriaLabel();
}
private _setActiveTool(label: string | undefined, icon: ThemeIcon | undefined): void {
@@ -332,6 +343,8 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
this._displayedToolIcon = undefined;
this._displayedToolAccessibleLabel = undefined;
this._renderActiveToolIcon(undefined);
this.updateTooltip();
this.updateAriaLabel();
return;
}
if (!this._displayedToolLabel || this.accessibilityService.isMotionReduced()) {
@@ -458,19 +471,45 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem {
const resource = contextChatResource(this._context);
if (!resource) {
this._titleTracker.clear();
this._action.enabled = false;
this._setEnabled(false);
this._setResolvedTitle(undefined);
this._setModelName(undefined);
this._setStatus(undefined);
return;
}
this._titleTracker.value = autorun(reader => {
const chat = findSubagentChat(this.sessionsService, resource, reader)?.chat;
this._action.enabled = !!chat;
const match = findSubagentChat(this.sessionsService, resource, reader);
const chat = match?.chat;
const parentChat = chat?.origin?.parentChat
? match?.session.chats.read(reader).find(candidate => isEqual(candidate.resource, chat.origin?.parentChat))
: undefined;
const parentModelId = parentChat?.modelId.read(reader);
const parentModel = parentModelId ? this.languageModelsService.lookupLanguageModel(parentModelId) : undefined;
this._setEnabled(!!chat);
this._setResolvedTitle(chat?.title.read(reader) || undefined);
this._setModelName(shouldShowSubagentModel(this._reportedModelName, parentModelId, parentModel?.name, parentModel?.id) ? this._reportedModelName : undefined);
this._setStatus(chat?.status.read(reader));
});
}
private _setModelName(modelName: string | undefined): void {
if (modelName === this._modelName) {
return;
}
this._modelName = modelName;
if (this._modelElement) {
this._modelElement.textContent = modelName ?? '';
this._modelElement.classList.toggle('hidden', !modelName);
}
this.updateTooltip();
this.updateAriaLabel();
}
private _setEnabled(enabled: boolean): void {
this._action.enabled = enabled;
this._sourceAction.enabled = enabled;
}
private _updateDuration(): void {
this._durationTimer.cancel();
const timing = contextSubagentTiming(this._context);
@@ -5,22 +5,50 @@
import assert from 'assert';
import { Action } from '../../../../../../base/common/actions.js';
import { Event } from '../../../../../../base/common/event.js';
import { observableValue } from '../../../../../../base/common/observable.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js';
import { workbenchInstantiationService } from '../../../../../../workbench/test/browser/workbenchTestServices.js';
import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js';
import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js';
import { OpenSubagentChatActionViewItem } from '../../browser/openSubagentChat.js';
import { OpenSubagentChatActionViewItem, shouldShowSubagentModel } from '../../browser/openSubagentChat.js';
class TestOpenSubagentChatActionViewItem extends OpenSubagentChatActionViewItem {
get tooltip(): string | undefined {
return this.getTooltip();
}
}
suite('OpenSubagentChatActionViewItem', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
test('shows the subagent model unless it matches the parent model', () => {
assert.deepStrictEqual([
shouldShowSubagentModel(undefined, 'agent-host-copilotcli:gpt-5.6-sol', 'GPT-5.6 Sol', 'gpt-5.6-sol'),
shouldShowSubagentModel('GPT-5.6 Sol', undefined, undefined, undefined),
shouldShowSubagentModel('gpt-5.6-sol', 'agent-host-copilotcli:gpt-5.6-sol', 'GPT-5.6 Sol', 'gpt-5.6-sol'),
shouldShowSubagentModel('GPT-5.6 Sol', 'agent-host-copilotcli:gpt-5.6-sol', 'GPT-5.6 Sol', 'gpt-5.6-sol'),
shouldShowSubagentModel('Claude Opus 4.8', 'agent-host-copilotcli:gpt-5.6-sol', 'GPT-5.6 Sol', 'gpt-5.6-sol'),
], [
false,
true,
false,
false,
true,
]);
});
test('disables and hides the action until its peer chat resolves', () => {
const instantiationService = workbenchInstantiationService(undefined, store);
instantiationService.stub(ISessionsService, {
activeSession: observableValue<IActiveSession | undefined>('activeSession', undefined),
visibleSessions: observableValue<readonly (IActiveSession | undefined)[]>('visibleSessions', []),
});
instantiationService.stub(ILanguageModelsService, {
onDidChangeLanguageModels: Event.None,
lookupLanguageModel: () => undefined,
});
const action = store.add(new Action('openSubagent', 'Open Subagent'));
const viewItem = store.add(instantiationService.createInstance(
OpenSubagentChatActionViewItem,
@@ -37,11 +65,57 @@ suite('OpenSubagentChatActionViewItem', () => {
sourceActionEnabled: action.enabled,
hidden: container.classList.contains('hidden'),
ariaHidden: container.getAttribute('aria-hidden'),
modelHidden: container.querySelector('.chat-subagent-pill-model')?.classList.contains('hidden'),
}, {
enabled: false,
sourceActionEnabled: true,
sourceActionEnabled: false,
hidden: true,
ariaHidden: 'true',
modelHidden: true,
});
});
test('refreshes accessible metadata when the active tool clears', () => {
const instantiationService = workbenchInstantiationService(undefined, store);
instantiationService.stub(ISessionsService, {
activeSession: observableValue<IActiveSession | undefined>('activeSession', undefined),
visibleSessions: observableValue<readonly (IActiveSession | undefined)[]>('visibleSessions', []),
});
instantiationService.stub(ILanguageModelsService, {
onDidChangeLanguageModels: Event.None,
lookupLanguageModel: () => undefined,
});
const action = store.add(new Action('openSubagent', 'Open Subagent'));
const viewItem = store.add(instantiationService.createInstance(
TestOpenSubagentChatActionViewItem,
{ chatResource: 'ahp-chat://subagent/session/tool-call', activeToolLabel: 'Reading files' },
action,
{},
));
const container = document.createElement('div');
viewItem.render(container);
const withActiveTool = {
tooltip: viewItem.tooltip,
ariaLabel: container.getAttribute('aria-label'),
};
viewItem.setActionContext({ chatResource: 'ahp-chat://subagent/session/tool-call' });
assert.deepStrictEqual({
withActiveTool,
withoutActiveTool: {
tooltip: viewItem.tooltip,
ariaLabel: container.getAttribute('aria-label'),
},
}, {
withActiveTool: {
tooltip: 'Open Subagent\nActive tool: Reading files',
ariaLabel: 'Open Subagent. Active tool Reading files',
},
withoutActiveTool: {
tooltip: 'Open Subagent',
ariaLabel: 'Open Subagent',
},
});
});
});
@@ -96,6 +96,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui
content.push(localize('chat.voiceMode.introduction', 'The first time Voice Mode starts, an introduction appears above the input box. Tab to reach it, then use the arrow keys to move between the available voices; Enter or Space plays a voice and keeps it for future conversations. Its description also contains two links: Settings, which opens the Voice Mode settings, and How It Responds, which opens a file for customizing what the agent says back. Voice Mode stays connected but does not listen while the introduction is open. Press Escape, or activate the Close button, to dismiss it and return to the input box.'));
content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '<keybinding:editor.action.accessibleView>'));
content.push(localize('chat.inspectResponseThinkingToggle', 'To include or exclude thinking content in the accessible view, run the Toggle Thinking Content in Accessible View command from the Command Palette.'));
content.push(localize('chat.completedResponseDisclosure', 'When completed response collapsing is enabled, the final response remains visible while earlier work is collapsed. Use Tab to focus the work disclosure and press Enter or Space to show or hide that work.'));
content.push(localize('workbench.action.chat.focus', 'To focus the chat request and response list, invoke the Focus Chat command{0}. This will move focus to the most recent response, which you can then navigate using the up and down arrow keys.', getChatFocusKeybindingLabel(keybindingService, type, 'last')));
content.push(localize('workbench.action.chat.focusLastFocusedItem', 'To return to the last chat response you focused, invoke the Focus Last Focused Chat Response command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'lastFocused')));
content.push(localize('workbench.action.chat.focusInput', 'To focus the input box for chat requests, invoke the Focus Chat Input command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'input')));
@@ -491,6 +491,11 @@ configurationRegistry.registerConfiguration({
default: 'word',
tags: ['experimental'],
},
[ChatConfiguration.CollapseCompletedResponses]: {
type: 'boolean',
description: nls.localize('chat.agent.collapseCompletedResponses', "Controls whether completed chat responses collapse intermediate work while keeping the final response visible."),
default: product.quality !== 'stable',
},
'chat.detectParticipant.enabled': {
type: 'boolean',
description: nls.localize('chat.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."),
@@ -16,6 +16,7 @@ import { autorun } from '../../../../../../base/common/observable.js';
import { rcut } from '../../../../../../base/common/strings.js';
import { ThemeIcon } from '../../../../../../base/common/themables.js';
import { localize } from '../../../../../../nls.js';
import { IActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js';
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js';
import { MenuId } from '../../../../../../platform/actions/common/actions.js';
import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js';
@@ -251,6 +252,11 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
toolbarOptions: { primaryGroup: () => true },
}));
this._register(this._openChatToolbar.onDidChangeMenuItems(() => this._trackOpenChatActions()));
this._register(this.actionViewItemService.onDidChange(menuId => {
if (menuId === MenuId.ChatSubagentContent) {
this._trackOpenChatActions();
}
}));
this._trackOpenChatActions();
}
this._updateOpenChatToolbarContext();
@@ -328,6 +334,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
@IHoverService hoverService: IHoverService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
@IActionViewItemService private readonly actionViewItemService: IActionViewItemService,
) {
// Extract description, agentName, and prompt from toolInvocation
const { description, isDefaultDescription, agentName, prompt, modelName, credits } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation);
@@ -7,7 +7,8 @@
margin-bottom: 16px;
}
.interactive-item-container.editing-session.interactive-response > .value > .chat-codeblock-pill-container {
.interactive-item-container.editing-session.interactive-response > .value > .chat-codeblock-pill-container,
.interactive-item-container.editing-session.interactive-response > .value > .completed-response-disclosure > .chat-codeblock-pill-container {
margin-bottom: 14px;
}
@@ -62,7 +62,7 @@ import { getExplicitFileOrImageAttachmentSummary, IChatRequestVariableEntry, isE
import { getStickyScrollTargetItem, IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM, IChatTurnPillsPart } from '../../common/model/chatViewModel.js';
import { getNWords } from '../../common/model/chatWordCounter.js';
import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js';
import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../common/chatProgressFormatting.js';
import { formatChatRequestTimestamp, formatChatResponseDetails, formatChatResponseElapsedTime } from '../../common/chatProgressFormatting.js';
import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js';
import { ForkConversationActionId } from '../actions/chatForkActions.js';
import { MarkHelpfulActionId } from '../actions/chatTitleActions.js';
@@ -149,6 +149,12 @@ export interface IChatListItemTemplate {
* Element used to track whether the template is mounted in the DOM.
*/
renderedPartsMounted?: boolean;
renderedContent?: ReadonlyArray<IChatRendererContent>;
completedResponseDisclosure?: HTMLDetailsElement;
completedResponseStartIndex?: number;
completedResponseDisclosureOpen?: boolean;
wasResponseComplete?: boolean;
readonly completedResponseDisclosureDisposables: DisposableStore;
/** Drag handle element for reordering pending requests, if currently rendered. */
dragHandle?: HTMLElement;
@@ -238,6 +244,49 @@ export function shouldScheduleInitialHeightChange(normalizedHeight: number, allo
return typeof allocatedHeight !== 'number' || normalizedHeight > allocatedHeight;
}
export function getFinalResponseStartIndex(content: ReadonlyArray<IChatRendererContent>): number | undefined {
let index = content.length - 1;
while (index >= 0) {
const part = content[index];
if (part.kind === 'markdownContent' && part.content.value.length) {
break;
}
index--;
}
if (index < 0) {
return undefined;
}
while (index > 0 && content[index - 1].kind === 'markdownContent') {
index--;
}
return index;
}
export function formatCompletedResponseDisclosureLabel(stepCount: number, elapsedMs: number | undefined): string {
const elapsed = formatChatResponseElapsedTime(elapsedMs);
if (stepCount === 1) {
return elapsed
? localize('chat.responseCompletedOneStepIn', "Completed 1 step in {0}", elapsed)
: localize('chat.responseCompletedOneStep', "Completed 1 step");
}
return elapsed
? localize('chat.responseCompletedStepsIn', "Completed {0} steps in {1}", stepCount, elapsed)
: localize('chat.responseCompletedSteps', "Completed {0} steps", stepCount);
}
export function getVisibleCompletedResponseItemCount(nodes: ReadonlyArray<Node>): number {
let visibleItemCount = 0;
for (const node of nodes) {
if (dom.isHTMLElement(node) && (node.hidden || node.style.display === 'none')) {
continue;
}
visibleItemCount++;
}
return visibleItemCount;
}
/** How a freshly measured row height should be reconciled against the tree's known height. */
export type ChatItemHeightUpdateKind = 'none' | 'fire' | 'scheduleInitial' | 'deferReMeasure';
@@ -297,9 +346,7 @@ export function renderChatResponseDetails(container: HTMLElement, details: strin
container.classList.remove('chat-response-flip-active', 'chat-response-flip-down', 'chat-response-flip-reset');
const completion = verbose ? formatChatRequestTimestamp(completedAt) : undefined;
const elapsed = completion && typeof elapsedMs === 'number' && elapsedMs >= 1000
? formatElapsedTime(elapsedMs)
: undefined;
const elapsed = completion ? formatChatResponseElapsedTime(elapsedMs) : undefined;
const alternate = completion?.isRelative
? formatChatResponseDetails(elapsed, completion.fullText)
: elapsed;
@@ -826,6 +873,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
dom.append(detailContainer, $('span.chat-animated-ellipsis'));
const value = dom.append(valueParent, $('.value'));
const elementDisposables = templateDisposables.add(new DisposableStore());
const completedResponseDisclosureDisposables = templateDisposables.add(new DisposableStore());
const footerToolbarContainer = dom.append(rowContainer, $('.chat-footer-toolbar'));
if (this.rendererOptions.noFooter) {
@@ -898,7 +946,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}));
const connectionObserver = document.createElement('connection-observer') as dom.ConnectionObserverElement;
dom.append(container, connectionObserver);
const template: IChatListItemTemplate = { header, avatarContainer, requestHover, username, detail, value, rowContainer, elementDisposables, templateDisposables, contextKeyService, instantiationService: scopedInstantiationService, agentHover, titleToolbar, footerToolbar, footerToolbarContainer, footerDetailsContainer, disabledOverlay, checkpointToolbar, checkpointRestoreToolbar, checkpointContainer, checkpointRestoreContainer };
const template: IChatListItemTemplate = { header, avatarContainer, requestHover, username, detail, value, rowContainer, elementDisposables, templateDisposables, contextKeyService, instantiationService: scopedInstantiationService, agentHover, titleToolbar, footerToolbar, footerToolbarContainer, footerDetailsContainer, disabledOverlay, checkpointToolbar, checkpointRestoreToolbar, checkpointContainer, checkpointRestoreContainer, completedResponseDisclosureDisposables };
this.templateDataByRow.set(rowContainer, template);
templateDisposables.add(this._onDidUpdateViewModel.event(() => {
@@ -987,9 +1035,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
* so they can be reused when a new render is started.
*/
private clearRenderedParts(templateData: IChatListItemTemplate): void {
this.removeCompletedResponseDisclosure(templateData);
if (templateData.renderedParts) {
dispose(coalesce(templateData.renderedParts));
templateData.renderedParts = undefined;
templateData.renderedContent = undefined;
dom.clearNode(templateData.value);
} else if (isPendingDividerVM(templateData.currentElement)) {
dom.clearNode(templateData.value);
@@ -1007,6 +1057,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
templateData.checkpointToolbar.context = undefined;
templateData.checkpointRestoreToolbar.context = undefined;
templateData.currentElement = undefined;
templateData.completedResponseDisclosureOpen = undefined;
templateData.completedResponseStartIndex = undefined;
templateData.wasResponseComplete = undefined;
}
private renderChatTreeItem(element: ChatTreeItem, index: number, templateData: IChatListItemTemplate): void {
@@ -1132,6 +1185,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
updateVerboseDetails();
updateResponseDetails();
}
if (e.affectsConfiguration(ChatConfiguration.CollapseCompletedResponses) && isResponseVM(element)) {
this.updateCompletedResponseDisclosure(element, templateData.renderedContent ?? [], templateData, false);
}
}));
if (!this.rendererOptions.noHeader) {
@@ -2069,6 +2125,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
private renderChatContentDiff(partsToRender: ReadonlyArray<IChatRendererContent | null>, contentForThisTurn: ReadonlyArray<IChatRendererContent>, element: IChatResponseViewModel, elementIndex: number, templateData: IChatListItemTemplate): void {
const renderedParts = templateData.renderedParts ?? [];
templateData.renderedParts = renderedParts;
templateData.renderedContent = contentForThisTurn;
let codeBlockStartIndex = 0;
let treeStartIndex = 0;
let displacedWorkingPart: ChatWorkingProgressContentPart | undefined;
@@ -2216,6 +2273,116 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
delete renderedParts[i];
}
}
const animateCollapse = templateData.wasResponseComplete === false && element.isComplete;
this.updateCompletedResponseDisclosure(element, contentForThisTurn, templateData, animateCollapse);
templateData.wasResponseComplete = element.isComplete;
}
private updateCompletedResponseDisclosure(element: IChatResponseViewModel, content: ReadonlyArray<IChatRendererContent>, templateData: IChatListItemTemplate, animateCollapse: boolean): void {
if (!element.isComplete || !this.configService.getValue<boolean>(ChatConfiguration.CollapseCompletedResponses)) {
this.removeCompletedResponseDisclosure(templateData);
templateData.completedResponseDisclosureOpen = undefined;
return;
}
const finalResponseStartIndex = getFinalResponseStartIndex(content);
if (finalResponseStartIndex === undefined || finalResponseStartIndex === 0 || !content.slice(0, finalResponseStartIndex).some(part => part.kind !== 'references' || part.references.length > 0)) {
this.removeCompletedResponseDisclosure(templateData);
return;
}
const finalResponseNode = templateData.renderedParts?.[finalResponseStartIndex]?.domNode;
if (!finalResponseNode) {
this.removeCompletedResponseDisclosure(templateData);
return;
}
let finalResponseRoot = finalResponseNode;
while (finalResponseRoot.parentElement && finalResponseRoot.parentElement !== templateData.value) {
finalResponseRoot = finalResponseRoot.parentElement;
}
if (finalResponseRoot.parentElement !== templateData.value) {
this.removeCompletedResponseDisclosure(templateData);
return;
}
const existingDisclosure = templateData.completedResponseDisclosure;
if (existingDisclosure
&& templateData.completedResponseStartIndex === finalResponseStartIndex
&& existingDisclosure.nextSibling === finalResponseRoot
&& templateData.renderedParts?.slice(0, finalResponseStartIndex).every(part => !part?.domNode || existingDisclosure.contains(part.domNode))
) {
return;
}
this.removeCompletedResponseDisclosure(templateData);
const valueChildren = Array.from(templateData.value.childNodes);
const nodesToCollapse = valueChildren.slice(0, valueChildren.indexOf(finalResponseRoot));
const stepCount = getVisibleCompletedResponseItemCount(nodesToCollapse);
if (stepCount < 2) {
return;
}
const details = document.createElement('details');
details.classList.add('completed-response-disclosure');
const summary = details.appendChild(document.createElement('summary'));
summary.classList.add('completed-response-summary', 'chat-used-context-label');
const button = summary.appendChild($('span.monaco-button.monaco-text-button.monaco-icon-button'));
const label = button.appendChild($('span.monaco-button-mdlabel'));
const chevron = button.appendChild($('span.chat-collapsible-hover-chevron', { 'aria-hidden': 'true' }));
chevron.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronRight));
label.textContent = formatCompletedResponseDisclosureLabel(stepCount, element.model.elapsedMs);
const activeElement = dom.getActiveElement();
const keepOpenForFocus = nodesToCollapse.some(node => node.contains(activeElement));
const shouldAnimateInitialCollapse = animateCollapse
&& !keepOpenForFocus
&& !this.accessibilityService.isMotionReduced()
&& templateData.completedResponseDisclosureOpen === undefined;
if (keepOpenForFocus) {
templateData.completedResponseDisclosureOpen = true;
}
details.open = templateData.completedResponseDisclosureOpen ?? shouldAnimateInitialCollapse;
const updateExpansionState = () => {
summary.setAttribute('aria-expanded', String(details.open));
chevron.classList.toggle('expanded', details.open);
};
updateExpansionState();
templateData.value.insertBefore(details, finalResponseRoot);
details.append(...nodesToCollapse);
templateData.completedResponseDisclosure = details;
templateData.completedResponseStartIndex = finalResponseStartIndex;
templateData.completedResponseDisclosureDisposables.add(dom.addDisposableListener(details, 'toggle', () => {
templateData.completedResponseDisclosureOpen = details.open;
updateExpansionState();
}));
if (shouldAnimateInitialCollapse) {
const targetWindow = dom.getWindow(details);
const animationFrame = targetWindow.requestAnimationFrame(() => {
if (templateData.completedResponseDisclosure === details && details.open) {
details.open = false;
}
});
templateData.completedResponseDisclosureDisposables.add(toDisposable(() => targetWindow.cancelAnimationFrame(animationFrame)));
}
}
private removeCompletedResponseDisclosure(templateData: IChatListItemTemplate): void {
const details = templateData.completedResponseDisclosure;
if (!details) {
return;
}
templateData.completedResponseDisclosureDisposables.clear();
while (details.childNodes.length > 1) {
details.before(details.childNodes[1]);
}
details.remove();
templateData.completedResponseDisclosure = undefined;
templateData.completedResponseStartIndex = undefined;
}
/**
@@ -433,6 +433,84 @@
width: 100%;
}
.interactive-item-container .completed-response-disclosure {
width: 100%;
margin-bottom: var(--vscode-spacing-size80);
interpolate-size: allow-keywords;
&::details-content {
block-size: 0;
box-sizing: border-box;
overflow: hidden;
opacity: 0;
margin-inline-start: var(--vscode-spacing-size60);
padding-inline-start: var(--vscode-spacing-size120);
border-inline-start: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder);
border-image: linear-gradient(to bottom, var(--vscode-chat-requestBorder) 0 95%, transparent 95%) 1;
transition:
block-size 180ms cubic-bezier(0.2, 0, 0, 1),
opacity 140ms cubic-bezier(0.2, 0, 0, 1),
content-visibility 180ms allow-discrete;
}
&[open]::details-content {
block-size: auto;
opacity: 1;
}
}
.interactive-item-container .completed-response-disclosure:not([open]) {
margin-bottom: var(--vscode-spacing-size160);
}
.interactive-item-container .completed-response-summary {
width: fit-content;
max-width: 100%;
padding: 0;
border-radius: var(--vscode-cornerRadius-small);
cursor: pointer;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
list-style: none;
}
.interactive-item-container .completed-response-summary * {
cursor: inherit;
}
.interactive-item-container .completed-response-summary::-webkit-details-marker {
display: none;
}
.interactive-item-container .completed-response-summary:focus-visible {
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
outline-offset: calc(-1 * var(--vscode-strokeThickness));
}
.interactive-item-container .completed-response-disclosure[open] .completed-response-summary .monaco-button {
color: var(--vscode-foreground);
}
.interactive-item-container .completed-response-disclosure[open] .completed-response-summary {
padding-bottom: var(--vscode-spacing-size80);
}
.interactive-item-container .completed-response-disclosure > :last-child,
.interactive-item-container .completed-response-disclosure > :last-child.rendered-markdown > :last-child {
margin-bottom: 0;
}
.monaco-reduce-motion .completed-response-disclosure::details-content,
.monaco-workbench.monaco-reduce-motion .completed-response-disclosure::details-content {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.completed-response-disclosure::details-content {
transition: none;
}
}
.interactive-item-container > .value .chat-used-context {
margin-bottom: 14px;
}
@@ -652,12 +730,14 @@
font-family: var(--monaco-monospace-font);
}
.interactive-item-container .value > .rendered-markdown p {
.interactive-item-container .value > .rendered-markdown p,
.interactive-item-container .value > .completed-response-disclosure > .rendered-markdown p {
/* Targetting normal text paras. `p` can also appear in other elements/widgets */
margin: 0 0 16px 0;
}
.interactive-item-container .value > .chat-tool-invocation-part {
.interactive-item-container .value > .chat-tool-invocation-part,
.interactive-item-container .value > .completed-response-disclosure > .chat-tool-invocation-part {
.rendered-markdown p {
margin: 0 0 6px 0;
}
@@ -787,7 +867,8 @@
}
}
.interactive-item-container .value > .rendered-markdown li > p {
.interactive-item-container .value > .rendered-markdown li > p,
.interactive-item-container .value > .completed-response-disclosure > .rendered-markdown li > p {
margin: 0;
}
@@ -892,11 +973,13 @@ have to be updated for changes to the rules above, or to support more deeply nes
min-height: 0;
}
.interactive-item-container.interactive-item-compact .value > .rendered-markdown p {
.interactive-item-container.interactive-item-compact .value > .rendered-markdown p,
.interactive-item-container.interactive-item-compact .value > .completed-response-disclosure > .rendered-markdown p {
margin: 0 0 8px 0;
}
.interactive-item-container.interactive-item-compact .value > .rendered-markdown li > p {
.interactive-item-container.interactive-item-compact .value > .rendered-markdown li > p,
.interactive-item-container.interactive-item-compact .value > .completed-response-disclosure > .rendered-markdown li > p {
margin: 0;
}
@@ -43,6 +43,12 @@ export function formatElapsedTime(ms: number): string {
return localize('minutesSeconds', "{0}m {1}s", minutes, seconds);
}
export function formatChatResponseElapsedTime(elapsedMs: number | undefined): string | undefined {
return typeof elapsedMs === 'number' && elapsedMs >= 1000
? formatElapsedTime(elapsedMs)
: undefined;
}
export function formatChatRequestTimestamp(timestamp: number | undefined): IFormattedChatRequestTimestamp | undefined {
if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) {
return undefined;
@@ -59,6 +59,7 @@ export enum ChatConfiguration {
ThinkingStyle = 'chat.agent.thinkingStyle',
ThinkingGenerateTitles = 'chat.agent.thinking.generateTitles',
TerminalToolsInThinking = 'chat.agent.thinking.terminalTools',
CollapseCompletedResponses = 'chat.agent.collapseCompletedResponses',
SimpleTerminalCollapsible = 'chat.tools.terminal.simpleCollapsible',
CompressOutputEnabled = 'chat.tools.compressOutput.enabled',
ThinkingPhrases = 'chat.agent.thinking.phrases',
@@ -36,6 +36,26 @@ import { CollapsibleListPool } from '../../../../browser/widget/chatContentParts
import { ToolDataSource } from '../../../../common/tools/languageModelToolsService.js';
import { IAccessibilityService } from '../../../../../../../platform/accessibility/common/accessibility.js';
import { TestAccessibilityService } from '../../../../../../../platform/accessibility/test/common/testAccessibilityService.js';
import { IActionViewItemFactory, IActionViewItemService } from '../../../../../../../platform/actions/browser/actionViewItemService.js';
import { MenuId } from '../../../../../../../platform/actions/common/actions.js';
class TestActionViewItemService implements IActionViewItemService {
declare _serviceBrand: undefined;
private readonly _onDidChange = new Emitter<MenuId>();
readonly onDidChange = this._onDidChange.event;
fireDidChange(menuId: MenuId): void {
this._onDidChange.fire(menuId);
}
register(_menu: MenuId, _commandId: string | MenuId, _provider: IActionViewItemFactory): { dispose(): void } {
return { dispose: () => { } };
}
lookUp(_menu: MenuId, _commandId: string | MenuId): IActionViewItemFactory | undefined {
return undefined;
}
}
suite('ChatSubagentContentPart', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
@@ -50,6 +70,7 @@ suite('ChatSubagentContentPart', () => {
let mockListPool: CollapsibleListPool;
let mockEditorPool: EditorPool;
let announcedToolProgressKeys: Set<string>;
let actionViewItemService: TestActionViewItemService;
function createMockRenderContext(isComplete: boolean = false): IChatContentPartRenderContext {
const mockElement: Partial<IChatResponseViewModel> = {
@@ -252,6 +273,8 @@ suite('ChatSubagentContentPart', () => {
instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService {
override isMotionReduced(): boolean { return false; }
}());
actionViewItemService = new TestActionViewItemService();
instantiationService.stub(IActionViewItemService, actionViewItemService);
// Mock list pool and editor pool
mockListPool = {} as CollapsibleListPool;
@@ -382,6 +405,36 @@ suite('ChatSubagentContentPart', () => {
});
});
test('should hydrate open-chat-only mode when the action view registers after rendering', () => {
const part = createPart(createMockToolInvocation({
toolSpecificData: {
kind: 'subagent',
description: 'Test subagent description',
chatResource: 'ahp-chat://subagent/test/tool-call',
}
}), createMockRenderContext(false));
setOpenChatOnlyMode(part, false);
const toolbar = (part as unknown as { _openChatToolbar?: { getItemsLength(): number; getItemAction(index: number): Action | undefined } })._openChatToolbar;
assert.ok(toolbar);
const hydratedAction = store.add(new Action('openSubagent', 'Open Subagent', '', true));
toolbar.getItemsLength = () => 1;
toolbar.getItemAction = () => hydratedAction;
actionViewItemService.fireDidChange(MenuId.ChatSubagentContent);
const collapseButton = getCollapseButton(part);
const animationContainer = part.domNode.querySelector<HTMLElement>('.chat-collapsible-content-animation');
assert.deepStrictEqual({
openChatOnlyClass: part.domNode.classList.contains('chat-subagent-open-chat-only'),
collapseButtonDisplay: collapseButton?.style.display,
animationDisplay: animationContainer?.style.display,
}, {
openChatOnlyClass: true,
collapseButtonDisplay: 'none',
animationDisplay: 'none',
});
});
test('should preserve the collapsible surface when the open-chat action is unavailable', () => {
const part = createPart(createMockToolInvocation({
toolSpecificData: {
@@ -16,7 +16,7 @@ import { TestConfigurationService } from '../../../../../../platform/configurati
import { URI } from '../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js';
import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithSubagentContent, getWorkingProgressRelevantParts, IChatListItemTemplate, isWaitingForMcpServers, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js';
import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithSubagentContent, formatCompletedResponseDisclosureLabel, getFinalResponseStartIndex, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isWaitingForMcpServers, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js';
import { isChatTurnStatusPillsEnabled } from '../../../browser/widget/chatTurnPills.js';
import { IChatMcpServersStartingSlow, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js';
import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js';
@@ -49,6 +49,59 @@ suite('ChatListRenderer', () => {
true,
]);
});
suite('getFinalResponseStartIndex', () => {
test('finds the trailing markdown response while leaving trailing adjuncts in place', () => {
assert.deepStrictEqual([
getFinalResponseStartIndex([
{ kind: 'references', references: [] },
{ kind: 'markdownContent', content: new MarkdownString('Final response') },
{ kind: 'references', references: [] },
]),
getFinalResponseStartIndex([
{ kind: 'markdownContent', content: new MarkdownString('Earlier response') },
{ kind: 'references', references: [] },
{ kind: 'markdownContent', content: new MarkdownString('First segment') },
{ kind: 'markdownContent', content: new MarkdownString('Second segment') },
]),
getFinalResponseStartIndex([
{ kind: 'references', references: [] },
{ kind: 'markdownContent', content: new MarkdownString('') },
]),
], [
1,
2,
undefined,
]);
});
test('formats completed response disclosure step count and timing', () => {
assert.deepStrictEqual([
formatCompletedResponseDisclosureLabel(1, 83_000),
formatCompletedResponseDisclosureLabel(6, 83_000),
formatCompletedResponseDisclosureLabel(6, undefined),
], [
'Completed 1 step in 1m 23s',
'Completed 6 steps in 1m 23s',
'Completed 6 steps',
]);
});
test('counts visible completed response items', () => {
const hidden = document.createElement('div');
hidden.style.display = 'none';
const first = document.createElement('div');
const second = document.createElement('div');
assert.deepStrictEqual([
getVisibleCompletedResponseItemCount([hidden, first]),
getVisibleCompletedResponseItemCount([hidden, first, second]),
], [
1,
2,
]);
});
});
});
suite('reconcileChatItemHeight', () => {