diff --git a/resources/profiles/agent-sessions.code-profile b/resources/profiles/agent-sessions.code-profile index 2df6531bfca81..815ec6a3bcbf1 100644 --- a/resources/profiles/agent-sessions.code-profile +++ b/resources/profiles/agent-sessions.code-profile @@ -2,27 +2,29 @@ "name": "Agent Sessions", "icon": "vscode", "settings": { - "workbench.statusBar.visible": false, - "workbench.activityBar.location": "hidden", - "workbench.sideBar.location": "right", - "workbench.secondarySideBar.defaultVisibility": "maximized", - "workbench.secondarySideBar.restoreMaximized": true, - "chat.restoreLastPanelSession": true, - "workbench.startupEditor": "none", - "workbench.editor.restoreEditors": false, - "chat.agentsControl.enabled": true, - "chat.unifiedAgentsBar.enabled": true, - "files.autoSave": "afterDelay", - "window.title": "Agent Sessions", - "workbench.editor.showTabs": "single", - "workbench.colorTheme": "2026-dark-experimental", - "chat.agentsControl.triStateToggle": true, - "workbench.tips.enabled": false, - "chat.agent.maxRequests": 1000, - "workbench.layoutControl.type": "toggles", - "inlineChat.affordance": "editor", - "inlineChat.renderMode": "hover", - "github.copilot.chat.claudeCode.enabled": true, - "github.copilot.chat.languageContext.typescript.enabled": true + "workbench.statusBar.visible": false, + "workbench.activityBar.location": "hidden", + "workbench.sideBar.location": "right", + "workbench.secondarySideBar.defaultVisibility": "maximized", + "workbench.secondarySideBar.restoreMaximized": true, + "chat.restoreLastPanelSession": true, + "workbench.startupEditor": "none", + "workbench.editor.restoreEditors": false, + "chat.agentsControl.enabled": true, + "chat.unifiedAgentsBar.enabled": true, + "files.autoSave": "afterDelay", + "window.title": "Agent Sessions", + "workbench.editor.showTabs": "single", + "workbench.colorTheme": "2026-dark-experimental", + "chat.agentsControl.triStateToggle": true, + "workbench.tips.enabled": false, + "chat.agent.maxRequests": 1000, + "workbench.layoutControl.type": "toggles", + "inlineChat.affordance": "editor", + "inlineChat.renderMode": "hover", + "github.copilot.chat.claudeCode.enabled": true, + "github.copilot.chat.languageContext.typescript.enabled": true, + "diffEditor.renderSideBySide": false, + "diffEditor.hideUnchangedRegions.enabled": true } } diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 3ec1bec01ca71..6a9340b4c1db7 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -348,14 +348,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._proxy = this._extHostContext.getProxy(ExtHostContext.ExtHostChatSessions); - this._chatSessionsService.setOptionsChangeCallback(async (sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>) => { + this._register(this._chatSessionsService.onRequestNotifyExtension(({ sessionResource, updates, waitUntil }) => { const handle = this._getHandleForSessionType(sessionResource.scheme); + this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.length} update(s)`); if (handle !== undefined) { - await this.notifyOptionsChange(handle, sessionResource, updates); + waitUntil(this.notifyOptionsChange(handle, sessionResource, updates)); } else { this._logService.warn(`[MainThreadChatSessions] Cannot notify option change for scheme '${sessionResource.scheme}': no provider registered. Registered schemes: [${Array.from(this._sessionTypeToHandle.keys()).join(', ')}]`); } - }); + })); this._register(this._agentSessionsService.model.onDidChangeSessionArchivedState(session => { for (const [handle, { provider }] of this._itemProvidersRegistrations) { @@ -709,10 +710,12 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat * Notify the extension about option changes for a session */ async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>): Promise { + this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: starting proxy call for handle ${handle}, sessionResource ${sessionResource}`); try { await this._proxy.$provideHandleOptionsChange(handle, sessionResource, updates, CancellationToken.None); + this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: proxy call completed for handle ${handle}, sessionResource ${sessionResource}`); } catch (error) { - this._logService.error(`Error notifying extension about options change for handle ${handle}, sessionResource ${sessionResource}:`, error); + this._logService.error(`[MainThreadChatSessions] notifyOptionsChange: error for handle ${handle}, sessionResource ${sessionResource}:`, error); } } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index ae320452a1d6d..d67e1a23322f6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -7,7 +7,7 @@ import { sep } from '../../../../../base/common/path.js'; import { raceCancellationError } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; +import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -31,7 +31,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, SessionOptionsChangedCallback } from '../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -277,6 +277,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ public get onDidChangeSessionOptions() { return this._onDidChangeSessionOptions.event; } private readonly _onDidChangeOptionGroups = this._register(new Emitter()); public get onDidChangeOptionGroups() { return this._onDidChangeOptionGroups.event; } + private readonly _onRequestNotifyExtension = this._register(new AsyncEmitter()); + public get onRequestNotifyExtension() { return this._onRequestNotifyExtension.event; } private readonly inProgressMap: Map = new Map(); private readonly _sessionTypeOptions: Map = new Map(); @@ -1030,15 +1032,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._sessionTypeOptions.get(chatSessionType); } - private _optionsChangeCallback?: SessionOptionsChangedCallback; - - /** - * Set the callback for notifying extensions about option changes - */ - public setOptionsChangeCallback(callback: SessionOptionsChangedCallback): void { - this._optionsChangeCallback = callback; - } - /** * Notify extension about option changes for a session */ @@ -1046,13 +1039,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ if (!updates.length) { return; } - if (this._optionsChangeCallback) { - await this._optionsChangeCallback(sessionResource, updates); - } + this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: starting for ${sessionResource}, ${updates.length} update(s): [${updates.map(u => u.optionId).join(', ')}]`); + // Fire event to notify MainThreadChatSessions (which forwards to extension host) + // Uses fireAsync to properly await async listener work via waitUntil pattern + await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); + this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: fireAsync completed for ${sessionResource}`); for (const u of updates) { this.setSessionOption(sessionResource, u.optionId, u.value); } this._onDidChangeSessionOptions.fire(sessionResource); + this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: finished for ${sessionResource}`); } /** 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 c015c2020aaea..f57073fdf25e2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -6,6 +6,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { $, AnimationFrameScheduler, DisposableResizeObserver } from '../../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { rcut } from '../../../../../../base/common/strings.js'; import { localize } from '../../../../../../nls.js'; @@ -17,7 +18,7 @@ import { ChatTreeItem } from '../../chat.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { ChatCollapsibleMarkdownContentPart } from './chatCollapsibleMarkdownContentPart.js'; -import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { IRunSubagentToolInputParams, RunSubagentTool } from '../../../common/tools/builtinTools/runSubagentTool.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; @@ -36,11 +37,22 @@ const MAX_TITLE_LENGTH = 100; * Represents a lazy tool item that will be created when the subagent section is expanded. */ interface ILazyToolItem { + kind: 'tool'; lazy: Lazy; toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized; codeBlockStartIndex: number; } +/** + * Represents a lazy markdown item (e.g., edit pill) that will be rendered when expanded. + */ +interface ILazyMarkdownItem { + kind: 'markdown'; + lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; +} + +type ILazyItem = ILazyToolItem | ILazyMarkdownItem; + /** * This is generally copied from ChatThinkingContentPart. We are still experimenting with both UIs so I'm not * trying to refactor to share code. Both could probably be simplified when stable. @@ -59,7 +71,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private prompt: string | undefined; // Lazy rendering support - private readonly lazyItems: ILazyToolItem[] = []; + private readonly lazyItems: ILazyItem[] = []; private hasExpandedOnce: boolean = false; private pendingPromptRender: boolean = false; private pendingResultText: string | undefined; @@ -500,6 +512,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } else { // Defer rendering until expanded const item: ILazyToolItem = { + kind: 'tool', lazy: new Lazy(() => this.createToolPart(toolInvocation, codeBlockStartIndex)), toolInvocation, codeBlockStartIndex, @@ -508,6 +521,61 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } } + /** + * Appends a markdown item (e.g., an edit pill) to the subagent content part. + * This is used to route codeblockUri parts with subAgentInvocationId to this subagent's container. + */ + public appendMarkdownItem( + factory: () => { domNode: HTMLElement; disposable?: IDisposable }, + _codeblocksPartId: string | undefined, + _markdown: IChatMarkdownContent, + _originalParent?: HTMLElement + ): void { + // If expanded or has been expanded once, render immediately + if (this.isExpanded() || this.hasExpandedOnce) { + const result = factory(); + this.appendMarkdownItemToDOM(result.domNode); + if (result.disposable) { + this._register(result.disposable); + } + } else { + // Defer rendering until expanded + const item: ILazyMarkdownItem = { + kind: 'markdown', + lazy: new Lazy(factory), + }; + this.lazyItems.push(item); + } + } + + /** + * Appends a markdown item's DOM node to the wrapper. + */ + private appendMarkdownItemToDOM(domNode: HTMLElement): void { + if (!domNode.hasChildNodes() || domNode.textContent?.trim() === '') { + return; + } + + // Wrap with icon like other items + const itemWrapper = $('.chat-thinking-tool-wrapper'); + const iconElement = createThinkingIcon(Codicon.edit); + itemWrapper.appendChild(domNode); + itemWrapper.insertBefore(iconElement, itemWrapper.firstChild); + + // Insert before result container if it exists, otherwise append + if (this.wrapper) { + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); + } else { + this.wrapper.appendChild(itemWrapper); + } + } + this.lastItemWrapper = itemWrapper; + + // Schedule layout to measure last item and scroll + this.layoutScheduler.schedule(); + } + protected override shouldInitEarly(): boolean { // Never init early - subagent is collapsed while running, content only shown on expand return false; @@ -582,15 +650,23 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } /** - * Materializes a lazy tool item by creating the tool part and adding it to the DOM. + * Materializes a lazy item by creating the content and adding it to the DOM. */ - private materializeLazyItem(item: ILazyToolItem): void { + private materializeLazyItem(item: ILazyItem): void { if (item.lazy.hasValue) { return; // Already materialized } - const part = item.lazy.value; - this.appendToolPartToDOM(part, item.toolInvocation); + if (item.kind === 'tool') { + const part = item.lazy.value; + this.appendToolPartToDOM(part, item.toolInvocation); + } else if (item.kind === 'markdown') { + const result = item.lazy.value; + this.appendMarkdownItemToDOM(result.domNode); + if (result.disposable) { + this._register(result.disposable); + } + } } /** @@ -640,6 +716,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + if (other.kind === 'markdownContent') { + return true; + } + // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || other.toolId === RunSubagentTool.Id)) { // For runSubagent tool, use toolCallId as the effective ID diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 9bf3894f258ef..a2fb8b710b1c4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -242,5 +242,5 @@ .interactive-session .interactive-response .value .chat-thinking-box .chat-used-context-label code { display: inline; line-height: inherit; - padding: 0 4px; + padding: 1px 3px; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 59887e7599c40..e10f47e4ca002 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -48,7 +48,7 @@ import { IThemeService } from '../../../../../platform/theme/common/themeService import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; -import { annotateSpecialMarkdownContent, hasCodeblockUriTag } from '../../common/widget/annotations.js'; +import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasCodeblockUriTag } from '../../common/widget/annotations.js'; import { checkModeOption } from '../../common/chat.js'; import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -1939,6 +1939,21 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer ({ domNode: markdownPart.domNode, disposable: markdownPart }), + markdownPart.codeblocksPartId, + markdown, + templateData.value + ); + return subagentPart; + } + } + // create thinking part if it doesn't exist yet const lastThinking = this.getLastThinkingPart(templateData.renderedParts); if (!lastThinking && markdownPart?.domNode && this.shouldPinPart(markdown, context.element) && collapsedToolsMode === CollapsedToolsDisplayMode.Always && isComplete) { 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 a4010246d0484..9521631d75588 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2076,6 +2076,7 @@ have to be updated for changes to the rules above, or to support more deeply nes display: flex; flex-direction: column; gap: 2px; + line-height: 1.5em; } .interactive-response-progress-tree, @@ -2173,6 +2174,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-used-context-label .monaco-button { padding: 2px 6px 2px 2px; font-size: var(--vscode-chat-font-size-body-m); + line-height: unset; } .interactive-session .chat-file-changes-label .monaco-button:hover { @@ -2204,8 +2206,8 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-item-container .progress-container { display: flex; align-items: center; - gap: 7px; - margin: 0 0 6px 4px; + gap: 4px; + margin: 0 0 6px 2px; /* Tool calls transition from a progress to a collapsible list part, which needs to have this top padding. The working progress also can be replaced by a tool progress part. So align this padding so the text doesn't appear to shift. */ @@ -2222,6 +2224,8 @@ have to be updated for changes to the rules above, or to support more deeply nes .codicon { /* Very aggressive list styles try to apply focus colors to every codicon in a list row. */ color: var(--vscode-icon-foreground) !important; + /* Matching the margin on all .monaco-text-button .codicon */ + margin: 0 .2em; &.codicon-error { color: var(--vscode-editorError-foreground) !important; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index b5a13cea9734d..40d74b4b25249 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -272,6 +272,7 @@ export interface IChatResponseCodeblockUriPart { uri: URI; isEdit?: boolean; undoStopId?: string; + subAgentInvocationId?: string; } export interface IChatAgentMarkdownContentWithVulnerability { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index df1091c4dcf73..14fda9d587171 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Event } from '../../../../base/common/event.js'; +import { Event, IWaitUntil } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; @@ -192,10 +192,14 @@ export interface IChatSessionContentProvider { provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; } -export type SessionOptionsChangedCallback = (sessionResource: URI, updates: ReadonlyArray<{ - optionId: string; - value: string | IChatSessionProviderOptionItem; -}>) => Promise; +/** + * Event fired when session options need to be sent to the extension. + * Extends IWaitUntil to allow listeners to register async work that will be awaited. + */ +export interface IChatSessionOptionsWillNotifyExtensionEvent extends IWaitUntil { + readonly sessionResource: URI; + readonly updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; +} export interface IChatSessionsService { readonly _serviceBrand: undefined; @@ -264,7 +268,12 @@ export interface IChatSessionsService { getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; - setOptionsChangeCallback(callback: SessionOptionsChangedCallback): void; + /** + * Event fired when session options change and need to be sent to the extension. + * MainThreadChatSessions subscribes to this to forward changes to the extension host. + * Uses IWaitUntil pattern to allow listeners to register async work. + */ + readonly onRequestNotifyExtension: Event; notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise; registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable; diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 45e8043847b07..646f3d935169f 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -187,6 +188,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Track whether we should collect markdown (after the last tool invocation) const markdownParts: string[] = []; + // Generate a stable subAgentInvocationId for routing edits to this subagent's content part + const subAgentInvocationId = invocation.callId ?? `subagent-${generateUuid()}`; + let inEdit = false; const progressCallback = (parts: IChatProgress[]) => { for (const part of parts) { @@ -196,7 +200,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { inEdit = true; model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n') }); } - model.acceptResponseProgress(request, part); + // Attach subAgentInvocationId to codeblockUri parts so they can be routed to the subagent content part + if (part.kind === 'codeblockUri') { + model.acceptResponseProgress(request, { ...part, subAgentInvocationId }); + } else { + model.acceptResponseProgress(request, part); + } // When we see a tool invocation starting, reset markdown collection if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index a1dbed9eb8968..c22c60ca4302b 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -60,7 +60,8 @@ export function annotateSpecialMarkdownContent(response: Iterable${item.uri.toString()}`; + const subAgentText = item.subAgentInvocationId ? ` subAgentInvocationId="${encodeURIComponent(item.subAgentInvocationId)}"` : ''; + const markdownText = `${item.uri.toString()}`; const merged = appendMarkdownString(previousItem.content, new MarkdownString(markdownText)); // delete the previous and append to ensure that we don't reorder the edit before the undo stop containing it result.splice(previousItemIndex, 1); @@ -79,14 +80,34 @@ export interface IMarkdownVulnerability { readonly description: string; readonly range: IRange; } -export function extractCodeblockUrisFromText(text: string): { uri: URI; isEdit?: boolean; textWithoutResult: string } | undefined { - const match = /(.*?)<\/vscode_codeblock_uri>/ms.exec(text); +export function extractCodeblockUrisFromText(text: string): { uri: URI; isEdit?: boolean; subAgentInvocationId?: string; textWithoutResult: string } | undefined { + const match = /([\s\S]*?)<\/vscode_codeblock_uri>/ms.exec(text); if (match) { - const [all, isEdit, uriString] = match; + const [all, isEdit, , encodedSubAgentId, uriString] = match; if (uriString) { const result = URI.parse(uriString); const textWithoutResult = text.substring(0, match.index) + text.substring(match.index + all.length); - return { uri: result, textWithoutResult, isEdit: !!isEdit }; + let subAgentInvocationId: string | undefined; + if (encodedSubAgentId) { + try { + subAgentInvocationId = decodeURIComponent(encodedSubAgentId); + } catch { + subAgentInvocationId = encodedSubAgentId; + } + } + return { uri: result, textWithoutResult, isEdit: !!isEdit, subAgentInvocationId }; + } + } + return undefined; +} + +export function extractSubAgentInvocationIdFromText(text: string): string | undefined { + const match = /]* subAgentInvocationId="([^"]*)"/ms.exec(text); + if (match) { + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/common/widget/codeBlockModelCollection.ts b/src/vs/workbench/contrib/chat/common/widget/codeBlockModelCollection.ts index b6396b2105047..5236ff30add5e 100644 --- a/src/vs/workbench/contrib/chat/common/widget/codeBlockModelCollection.ts +++ b/src/vs/workbench/contrib/chat/common/widget/codeBlockModelCollection.ts @@ -29,6 +29,7 @@ export interface CodeBlockEntry { readonly vulns: readonly IMarkdownVulnerability[]; readonly codemapperUri?: URI; readonly isEdit?: boolean; + readonly subAgentInvocationId?: string; } export class CodeBlockModelCollection extends Disposable { @@ -39,6 +40,7 @@ export class CodeBlockModelCollection extends Disposable { inLanguageId: string | undefined; codemapperUri?: URI; isEdit?: boolean; + subAgentInvocationId?: string; }>(); /** @@ -85,6 +87,7 @@ export class CodeBlockModelCollection extends Disposable { vulns: entry.vulns, codemapperUri: entry.codemapperUri, isEdit: entry.isEdit, + subAgentInvocationId: entry.subAgentInvocationId, }; } @@ -195,6 +198,7 @@ export class CodeBlockModelCollection extends Disposable { if (entry) { entry.codemapperUri = codeblockUri.uri; entry.isEdit = codeblockUri.isEdit; + entry.subAgentInvocationId = codeblockUri.subAgentInvocationId; } newText = codeblockUri.textWithoutResult; 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 7a58f61d8d9e5..2884683f1a6d5 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 @@ -509,7 +509,7 @@ suite('ChatSubagentContentPart', () => { assert.strictEqual(result, true, 'Should match runSubagent tool using toolCallId as effective ID'); }); - test('should return false for non-subagent content', () => { + test('should return true for markdownContent (allowing grouping)', () => { const toolInvocation = createMockToolInvocation(); const context = createMockRenderContext(false); @@ -521,7 +521,7 @@ suite('ChatSubagentContentPart', () => { }; const result = part.hasSameContent(markdownContent, [], context.element); - assert.strictEqual(result, false, 'Should not match non-subagent content'); + assert.strictEqual(result, true, 'Should match markdownContent to allow grouping'); }); }); @@ -895,6 +895,201 @@ suite('ChatSubagentContentPart', () => { }); }); + suite('appendMarkdownItem', () => { + test('should append markdown item to expanded subagent part', () => { + const toolInvocation = createMockToolInvocation({ + subAgentInvocationId: 'test-subagent-id', + toolSpecificData: { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent' + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + // Expand the part first + const button = getCollapseButton(part); + button?.click(); + assert.strictEqual(part.domNode.classList.contains('chat-used-context-collapsed'), false, 'Should be expanded'); + + // Create a mock markdown content with edit pill + const markdownContent: IChatMarkdownContent = { + kind: 'markdownContent', + content: { value: 'Edited file.ts' } + }; + + // Create a mock DOM node for the markdown + const markdownDomNode = mainWindow.document.createElement('div'); + markdownDomNode.className = 'chat-codeblock-button'; + markdownDomNode.textContent = 'file.ts'; + + let disposeCallCount = 0; + const mockDisposable = { dispose: () => { disposeCallCount++; } }; + + // Append markdown item + part.appendMarkdownItem( + () => ({ domNode: markdownDomNode, disposable: mockDisposable }), + 'codeblock-123', + markdownContent, + undefined + ); + + // Verify the markdown was appended + const wrapper = getWrapperElement(part); + assert.ok(wrapper, 'Wrapper should exist'); + const appendedElement = wrapper.querySelector('.chat-codeblock-button'); + assert.ok(appendedElement, 'Appended markdown element should exist in wrapper'); + assert.strictEqual(appendedElement.textContent, 'file.ts', 'Should have correct content'); + }); + + test('should not render markdown item when part is collapsed', () => { + const toolInvocation = createMockToolInvocation({ + subAgentInvocationId: 'test-subagent-defer', + toolSpecificData: { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent' + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + // Part is collapsed by default + assert.ok(part.domNode.classList.contains('chat-used-context-collapsed'), 'Should start collapsed'); + + const markdownContent: IChatMarkdownContent = { + kind: 'markdownContent', + content: { value: 'Deferred edit' } + }; + + let factoryCalled = false; + const markdownDomNode = mainWindow.document.createElement('div'); + markdownDomNode.className = 'deferred-edit'; + markdownDomNode.textContent = 'deferred.ts'; + + const mockDisposable = { dispose: () => { } }; + + // Append markdown item while collapsed - factory should not be called + part.appendMarkdownItem( + () => { + factoryCalled = true; + return { domNode: markdownDomNode, disposable: mockDisposable }; + }, + 'codeblock-deferred', + markdownContent, + undefined + ); + + // Factory should not be called when collapsed + assert.strictEqual(factoryCalled, false, 'Factory should not be called when collapsed'); + }); + + test('should append multiple markdown items with same codeblock ID', () => { + const toolInvocation = createMockToolInvocation({ + subAgentInvocationId: 'test-subagent-dedup', + toolSpecificData: { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent' + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + // Expand the part + const button = getCollapseButton(part); + button?.click(); + + const markdownContent: IChatMarkdownContent = { + kind: 'markdownContent', + content: { value: 'Same codeblock' } + }; + + const sharedCodeblockId = 'codeblock-same-id'; + + // Append first item + const firstNode = mainWindow.document.createElement('div'); + firstNode.className = 'first-item'; + firstNode.textContent = 'first item content'; + part.appendMarkdownItem( + () => ({ domNode: firstNode, disposable: { dispose: () => { } } }), + sharedCodeblockId, + markdownContent, + undefined + ); + + // Append second item with same codeblock ID + const secondNode = mainWindow.document.createElement('div'); + secondNode.className = 'second-item'; + secondNode.textContent = 'second item content'; + part.appendMarkdownItem( + () => ({ domNode: secondNode, disposable: { dispose: () => { } } }), + sharedCodeblockId, + markdownContent, + undefined + ); + + // Both items are added (no built-in deduplication by codeblock ID) + const wrapper = getWrapperElement(part); + assert.ok(wrapper, 'Wrapper should exist'); + const firstItems = wrapper.querySelectorAll('.first-item'); + const secondItems = wrapper.querySelectorAll('.second-item'); + // Implementation does not deduplicate - both items exist + assert.strictEqual(firstItems.length, 1, 'First item should exist'); + assert.strictEqual(secondItems.length, 1, 'Second item should exist'); + }); + + test('should handle multiple different codeblock IDs', () => { + const toolInvocation = createMockToolInvocation({ + subAgentInvocationId: 'test-subagent-multi', + toolSpecificData: { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent' + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + // Expand the part + const button = getCollapseButton(part); + button?.click(); + + // Append first item + const firstNode = mainWindow.document.createElement('div'); + firstNode.className = 'item-one'; + firstNode.textContent = 'first item content'; + part.appendMarkdownItem( + () => ({ domNode: firstNode, disposable: { dispose: () => { } } }), + 'codeblock-1', + { kind: 'markdownContent', content: { value: 'First' } }, + undefined + ); + + // Append second item with different ID + const secondNode = mainWindow.document.createElement('div'); + secondNode.className = 'item-two'; + secondNode.textContent = 'second item content'; + part.appendMarkdownItem( + () => ({ domNode: secondNode, disposable: { dispose: () => { } } }), + 'codeblock-2', + { kind: 'markdownContent', content: { value: 'Second' } }, + undefined + ); + + // Both should exist + const wrapper = getWrapperElement(part); + assert.ok(wrapper, 'Wrapper should exist'); + assert.ok(wrapper.querySelector('.item-one'), 'First item should exist'); + assert.ok(wrapper.querySelector('.item-two'), 'Second item should exist'); + }); + }); + suite('Auto-expand on confirmation', () => { test('should auto-expand when tool state becomes WaitingForConfirmation', () => { const toolInvocation = createMockToolInvocation({ diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index ea9db8d7c8c70..80e7de23580f2 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../../base/common/event.js'; +import { AsyncEmitter, Emitter } from '../../../../../base/common/event.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -12,7 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionProviderOptionGroup, IChatSessionsExtensionPoint, IChatSessionsService, SessionOptionsChangedCallback } from '../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; @@ -37,6 +37,9 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onDidChangeOptionGroups = new Emitter(); readonly onDidChangeOptionGroups = this._onDidChangeOptionGroups.event; + private readonly _onRequestNotifyExtension = new AsyncEmitter(); + readonly onRequestNotifyExtension = this._onRequestNotifyExtension.event; + private sessionItemProviders = new Map(); private contentProviders = new Map(); private contributions: IChatSessionsExtensionPoint[] = []; @@ -160,14 +163,8 @@ export class MockChatSessionsService implements IChatSessionsService { } } - private optionsChangeCallback?: SessionOptionsChangedCallback; - - setOptionsChangeCallback(callback: SessionOptionsChangedCallback): void { - this.optionsChangeCallback = callback; - } - - async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string }>): Promise { - await this.optionsChangeCallback?.(sessionResource, updates); + async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { + await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); } notifySessionItemsChanged(chatSessionType: string): void { diff --git a/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts b/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts index 18e36131d3d09..65e1db898f4e9 100644 --- a/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { IChatMarkdownContent } from '../../../common/chatService/chatService.js'; -import { annotateSpecialMarkdownContent, extractVulnerabilitiesFromText } from '../../../common/widget/annotations.js'; +import { IChatMarkdownContent, IChatResponseCodeblockUriPart } from '../../../common/chatService/chatService.js'; +import { annotateSpecialMarkdownContent, extractCodeblockUrisFromText, extractSubAgentInvocationIdFromText, extractVulnerabilitiesFromText } from '../../../common/widget/annotations.js'; function content(str: string): IChatMarkdownContent { return { kind: 'markdownContent', content: new MarkdownString(str) }; @@ -58,4 +60,103 @@ suite('Annotations', function () { await assertSnapshot(result); }); }); + + suite('extractSubAgentInvocationIdFromText', () => { + test('extracts subAgentInvocationId from codeblock uri tag', () => { + const subAgentId = 'test-agent-123'; + const uri = URI.parse('file:///test.ts'); + const codeblockUriPart: IChatResponseCodeblockUriPart = { + kind: 'codeblockUri', + uri, + isEdit: true, + subAgentInvocationId: subAgentId + }; + const annotated = annotateSpecialMarkdownContent([content('code'), codeblockUriPart]); + const markdown = annotated[0] as IChatMarkdownContent; + + const result = extractSubAgentInvocationIdFromText(markdown.content.value); + assert.strictEqual(result, subAgentId); + }); + + test('returns undefined when no subAgentInvocationId', () => { + const uri = URI.parse('file:///test.ts'); + const codeblockUriPart: IChatResponseCodeblockUriPart = { + kind: 'codeblockUri', + uri, + isEdit: true + }; + const annotated = annotateSpecialMarkdownContent([content('code'), codeblockUriPart]); + const markdown = annotated[0] as IChatMarkdownContent; + + const result = extractSubAgentInvocationIdFromText(markdown.content.value); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for text without codeblock uri tag', () => { + const result = extractSubAgentInvocationIdFromText('some random text'); + assert.strictEqual(result, undefined); + }); + + test('handles special characters in subAgentInvocationId via URL encoding', () => { + const subAgentId = 'agent-with-special&chars=value'; + const uri = URI.parse('file:///test.ts'); + const codeblockUriPart: IChatResponseCodeblockUriPart = { + kind: 'codeblockUri', + uri, + isEdit: true, + subAgentInvocationId: subAgentId + }; + const annotated = annotateSpecialMarkdownContent([content('code'), codeblockUriPart]); + const markdown = annotated[0] as IChatMarkdownContent; + + const result = extractSubAgentInvocationIdFromText(markdown.content.value); + assert.strictEqual(result, subAgentId); + }); + + test('handles malformed URL encoding gracefully', () => { + // Manually construct a malformed tag with invalid URL encoding + const malformedTag = 'file:///test.ts'; + const result = extractSubAgentInvocationIdFromText(malformedTag); + // Should return the raw value when decoding fails + assert.strictEqual(result, '%ZZ'); + }); + }); + + suite('extractCodeblockUrisFromText with subAgentInvocationId', () => { + test('extracts subAgentInvocationId from codeblock uri', () => { + const subAgentId = 'test-subagent-456'; + const uri = URI.parse('file:///example.ts'); + const codeblockUriPart: IChatResponseCodeblockUriPart = { + kind: 'codeblockUri', + uri, + isEdit: true, + subAgentInvocationId: subAgentId + }; + const annotated = annotateSpecialMarkdownContent([content('code'), codeblockUriPart]); + const markdown = annotated[0] as IChatMarkdownContent; + + const result = extractCodeblockUrisFromText(markdown.content.value); + assert.ok(result); + assert.strictEqual(result.subAgentInvocationId, subAgentId); + assert.strictEqual(result.uri.toString(), uri.toString()); + assert.strictEqual(result.isEdit, true); + }); + + test('round-trip encoding/decoding with special characters', () => { + const subAgentId = 'agent/with spaces&special=chars?more'; + const uri = URI.parse('file:///path/to/file.ts'); + const codeblockUriPart: IChatResponseCodeblockUriPart = { + kind: 'codeblockUri', + uri, + isEdit: true, + subAgentInvocationId: subAgentId + }; + const annotated = annotateSpecialMarkdownContent([content('code'), codeblockUriPart]); + const markdown = annotated[0] as IChatMarkdownContent; + + const extracted = extractCodeblockUrisFromText(markdown.content.value); + assert.ok(extracted); + assert.strictEqual(extracted.subAgentInvocationId, subAgentId); + }); + }); }); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts index d13c53c9927cd..d5ff017820993 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts @@ -32,7 +32,6 @@ import { Categories } from '../../../../platform/action/common/actionCommonCateg import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { GettingStartedAccessibleView } from './gettingStartedAccessibleView.js'; -import product from '../../../../platform/product/common/product.js'; import { AgentSessionsWelcomePage } from '../../welcomeAgentSessions/browser/agentSessionsWelcome.js'; export * as icons from './gettingStartedIcons.js'; @@ -326,7 +325,7 @@ configurationRegistry.registerConfiguration({ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.terminal' }, "Open a new terminal in the editor area."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.agentSessionsWelcomePage' }, "Open the Agent Sessions Welcome page."), ], - 'default': typeof product.quality === 'string' && product.quality !== 'stable' ? 'agentSessionsWelcomePage' : 'welcomePage', + 'default': 'welcomePage', 'description': localize('workbench.startupEditor', "Controls which editor is shown at startup, if none are restored from the previous session.") }, 'workbench.welcomePage.preferReducedMotion': {