diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 051f6022d3f..1fa4a3778b0 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -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. diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 7381450719e..9e343d8a4b4 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -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; diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index bf2f49424bc..b662eb1d7eb 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -167,7 +167,7 @@ In the agent host, the real producer of read-only chats is **subagent (worker) c Subagent chats **persist** in the session catalog after the subagent completes (completion only marks the chat's turn complete; the chat is removed only when the whole session is disposed), so the read-only tab stays reviewable for the lifetime of the session. -**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The provider action stays disabled and out of visual and accessibility layout until its resource resolves to a surfaced peer chat, preventing a transient generic action while the chat catalog hydrates. `ChatSubagentContentPart` switches to `chat-subagent-open-chat-only` mode only while `MenuId.ChatSubagentContent` contains an enabled action; an unresolved or stale action therefore restores the normal collapsible subagent surface rather than leaving a blank row. The pill is a control-tier chip, not a fully rounded capsule, and is a sibling of the shared collapse button at the start of the header row. Do not rely on Agents-window CSS ancestry for this switch: the shared chat widget can be hosted through different DOM roots. The wider pill gives the subagent chat's own title priority as the leading label, with quiet, width-capped inline model metadata when the child turn reports its model; no duplicate agent-name phrase appears beside it. While the subagent is active, one single-line row attached below the pill shows the newest child tool with the same registered or inferred compact icon used by shared thinking-tool rendering. Terminal tools prefer the protocol's dedicated intention over their raw command invocation message; other tools use the SDK/provider-authored invocation message (falling back to the display name in the Agent Host adapter). The row uses shared chat markdown and file-widget rendering, so file references and inline commands retain the same rich tool presentation as editor chat; it reserves a fixed minimum line slot so swapping among text, code, and file chips does not shift surrounding content. Newer tool intents replace it with the rotating-placeholder wipe/shimmer transition whose phases follow the actual CSS animation lifecycle rather than duplicated delays; changes that cancel the animation or environments without animation support settle immediately. The effective `workbench.reduceMotion` preference controls both this transition and the pending-confirmation pulse, so forced motion overrides are honored and a preference change during a transition swaps immediately. Status uses the shared pixel spinner: the grid/dropper variant for `InProgress`, the ring variant for `NeedsInput`, and the conversation icon when complete. Normal progress remains neutral—the spinner is sufficient. A subagent with a queued confirmation gets the subtle sessions-list warning background pulse; the subagent whose confirmation is currently active above the input gets the stronger warning border/background. A numeric warning badge is shown only for two or more pending confirmations; one confirmation keeps the warning state without a redundant `1`. The carousel publishes its active subagent id for this presentation state only; confirmation ownership/routing is unchanged. The carousel's subagent reference remains scroll-to-context in regular/editor chat, but in the Agents window it invokes `workbench.action.chat.openAgentHostChat` to open the related read-only chat. A quiet italic duration sits outside the border: `Working for 10s` while active and `Worked for 10s` after completion. Timing starts from the child chat's actual first `activeTurn.startedAt`, updates once per second while active, and freezes from the completed turn duration. Those values are copied onto serialized subagent tool data so stopping or reloading cannot reset the display. `ChatSubagentContentPart` publishes timing, confirmation count, active-confirmation state, model name, and latest active tool label/icon through the toolbar action context. The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes `workbench.action.chat.openAgentHostChat` with the subagent chat URI; the sessions layer handler derives the chat id, finds the matching surfaced peer across visible sessions, and calls `sessionsService.openChat` to activate the existing tab. +**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The provider action stays disabled and out of visual and accessibility layout until its resource resolves to a surfaced peer chat, preventing a transient generic action while the chat catalog hydrates. `ChatSubagentContentPart` switches to `chat-subagent-open-chat-only` mode only while `MenuId.ChatSubagentContent` contains an enabled action; an unresolved or stale action therefore restores the normal collapsible subagent surface rather than leaving a blank row. It re-tracks the toolbar action when either the menu or its custom action-view registration changes, because late registration replaces the action instance whose enabled state drives this mode. The custom action view mirrors its resolved enabled state onto both its rendering proxy and the original menu action, which is the typed signal the shared subagent part observes to suppress the legacy surface. The pill is a control-tier chip, not a fully rounded capsule, and is a sibling of the shared collapse button at the start of the header row. Do not rely on Agents-window CSS ancestry for this switch: the shared chat widget can be hosted through different DOM roots. The wider pill gives the subagent chat's own title priority as the leading label, with quiet, width-capped inline model metadata that is shown by default and hidden only when the child turn's model matches the parent chat's selected model; canonical ids and registered display names are treated as equivalent, and an unresolved parent model still shows the metadata (a match cannot be established, so the model is surfaced). No duplicate agent-name phrase appears beside it. While the subagent is active, one single-line row attached below the pill shows the newest child tool with the same registered or inferred compact icon used by shared thinking-tool rendering. Terminal tools prefer the protocol's dedicated intention over their raw command invocation message; other tools use the SDK/provider-authored invocation message (falling back to the display name in the Agent Host adapter). The row uses shared chat markdown and file-widget rendering, so file references and inline commands retain the same rich tool presentation as editor chat; it reserves a fixed minimum line slot so swapping among text, code, and file chips does not shift surrounding content. Newer tool intents replace it with the rotating-placeholder wipe/shimmer transition whose phases follow the actual CSS animation lifecycle rather than duplicated delays; changes that cancel the animation or environments without animation support settle immediately. The effective `workbench.reduceMotion` preference controls both this transition and the pending-confirmation pulse, so forced motion overrides are honored and a preference change during a transition swaps immediately. Status uses the shared pixel spinner: the grid/dropper variant for `InProgress`, the ring variant for `NeedsInput`, and the conversation icon when complete. Normal progress remains neutral—the spinner is sufficient. A subagent with a queued confirmation gets the subtle sessions-list warning background pulse; the subagent whose confirmation is currently active above the input gets the stronger warning border/background. A numeric warning badge is shown only for two or more pending confirmations; one confirmation keeps the warning state without a redundant `1`. The carousel publishes its active subagent id for this presentation state only; confirmation ownership/routing is unchanged. The carousel's subagent reference remains scroll-to-context in regular/editor chat, but in the Agents window it invokes `workbench.action.chat.openAgentHostChat` to open the related read-only chat. A quiet italic duration sits outside the border: `Working for 10s` while active and `Worked for 10s` after completion. It uses tabular figures so once-per-second digit changes do not shift the surrounding label. Timing starts from the child chat's actual first `activeTurn.startedAt`, updates once per second while active, and freezes from the completed turn duration. Those values are copied onto serialized subagent tool data so stopping or reloading cannot reset the display. `ChatSubagentContentPart` publishes timing, confirmation count, active-confirmation state, model name, and latest active tool label/icon through the toolbar action context. The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes `workbench.action.chat.openAgentHostChat` with the subagent chat URI; the sessions layer handler derives the chat id, finds the matching surfaced peer across visible sessions, and calls `sessionsService.openChat` to activate the existing tab. **Confirmations in read-only subagent chats.** Read-only hides the composer, but the tool-confirmation carousel remains visible and keeps the input part in layout. This lets multi-chat/side-chat subagent views resolve their own confirmations without making the chat message composer interactive. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css b/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css index 6b7a2547228..0ee16a2385d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css +++ b/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css @@ -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; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts index 9911b0c7165..fc00c177ec2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts @@ -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); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts index 3f4682e0ea0..8b2adc7bfce 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts @@ -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('activeSession', undefined), visibleSessions: observableValue('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('activeSession', undefined), + visibleSessions: observableValue('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', + }, }); }); }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index db99b7aefe2..b0f1d9d3494 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -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.', '')); 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'))); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 94d1bb1522c..e00cc19748a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -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."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 1a478a40dcf..1f33bf28e54 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -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); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css index 83c91b6588e..064c9184d91 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css @@ -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; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index bc8463dcfc2..0191549f79b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -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; + 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): 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): 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 { @@ -987,9 +1035,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, contentForThisTurn: ReadonlyArray, 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, templateData: IChatListItemTemplate, animateCollapse: boolean): void { + if (!element.isComplete || !this.configService.getValue(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; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 05fa4f94c93..a9c7bc6f872 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -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; } diff --git a/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts b/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts index 603a4a3f463..2fd303ea426 100644 --- a/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts +++ b/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts @@ -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; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index d8a21b768c2..dac44beca18 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -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', diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 1363c71d5c3..580b393d37b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -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(); + 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; + let actionViewItemService: TestActionViewItemService; function createMockRenderContext(isComplete: boolean = false): IChatContentPartRenderContext { const mockElement: Partial = { @@ -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('.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: { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 2f804e81bc2..81b686afd64 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -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', () => {