From 9415abf1cc9eb0cfe0a3d373afb708cca3d3ba80 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 16:07:08 +0000 Subject: [PATCH 01/27] fix: guard against null in doRemoveFromValueTree (fixes #316034) typeof null === 'object' is true in JavaScript, so a null value in the configuration tree passes the existing type guard and Object.keys(null) throws 'Cannot convert undefined or null to object'. Add explicit null check to the guard condition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/configuration/common/configuration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/configuration/common/configuration.ts b/src/vs/platform/configuration/common/configuration.ts index e28c4a34a0a10..55eaae5451c23 100644 --- a/src/vs/platform/configuration/common/configuration.ts +++ b/src/vs/platform/configuration/common/configuration.ts @@ -302,7 +302,7 @@ function doRemoveFromValueTree(valueTree: IStringDictionary | unknown, if (Object.keys(valueTreeRecord).indexOf(first) !== -1) { const value = valueTreeRecord[first]; - if (typeof value === 'object' && !Array.isArray(value)) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { doRemoveFromValueTree(value, segments); if (Object.keys(value as object).length === 0) { delete valueTreeRecord[first]; From 9f70c92b79ffbc986fda32cf4146e354bd1dd168 Mon Sep 17 00:00:00 2001 From: Nikola Hristov Date: Tue, 12 May 2026 19:28:54 +0300 Subject: [PATCH 02/27] fix: restore protected modifier on relayCreationTimeoutMs in test helper The `relayCreationTimeoutMs` override in `TestableSSHRemoteAgentHostMainService` was missing its `protected` modifier, causing the mangler to promote it to `public` and fail the `compile-build-with-mangling` step with: ERROR: Protected fields have been made PUBLIC. This hurts minification and is therefore not allowed. Re-add `protected` to the override declaration to keep the field's visibility consistent with the base class and satisfy the mangler. --- .../agentHost/test/node/sshRemoteAgentHostService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts index faa69ebd589bb..912704bafa89e 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts @@ -174,7 +174,7 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic hangRelayCreationOnCall: number | undefined; /** Public override so tests can shorten the relay creation timeout. */ - override relayCreationTimeoutMs: number = 30_000; + protected override relayCreationTimeoutMs: number = 30_000; /** Stored onMessage callbacks from relays, most recent last. */ private readonly _relayMessageCallbacks: Array<(data: string) => void> = []; From c5061c3b6ac5ecbb6b579d2633846daa07376e66 Mon Sep 17 00:00:00 2001 From: Nikola Hristov Date: Tue, 12 May 2026 20:30:42 +0300 Subject: [PATCH 03/27] fix: expose relayCreationTimeoutMs via test-only setter to satisfy mangler The field `relayCreationTimeoutMs` is `protected` in the base class. The test subclass `TestableSSHRemoteAgentHostMainService` had overridden it without an explicit access modifier, making it implicitly `public`. The mangler correctly rejected this as it hurts minification. Adding `protected` back satisfied the mangler but caused a TS2445 error since the test was assigning to the field directly on an instance, which is illegal for `protected` members outside the class hierarchy. Fix by adding a narrow public setter `setRelayCreationTimeoutForTest` in the test subclass. The field stays `protected`; the setter provides a legitimate public entry point for tests. Signed-off-by: Nikola Hristov --- .../agentHost/test/node/sshRemoteAgentHostService.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts index 912704bafa89e..6e6c87109e504 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts @@ -279,6 +279,11 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic this._relayCloseCallbacks[this._relayCloseCallbacks.length - 1](); } } + + /** Sets the relay creation timeout; exposed for tests only. */ + setRelayCreationTimeoutForTest(ms: number): void { + this.relayCreationTimeoutMs = ms; + } } suite('SSHRemoteAgentHostMainService - connect flow', () => { @@ -1017,7 +1022,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { assert.strictEqual(originalClient.ended, false); // Use a short timeout so the test completes quickly. - service.relayCreationTimeoutMs = 50; + service.setRelayCreationTimeoutForTest(50); // Make the *reconnect* call's relay creation hang (the second relay). service.hangRelayCreationOnCall = 2; From 7326d2657545e36b31e5db3ebc9083f31a52b882 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 12 May 2026 15:35:53 -0700 Subject: [PATCH 04/27] Enable output renderers for code blocks in chat For #257761 --- extensions/mermaid-chat-features/package.json | 3 + .../browser/mainThreadChatOutputRenderer.ts | 16 +- .../workbench/api/common/extHost.protocol.ts | 8 +- .../api/common/extHostChatOutputRenderer.ts | 6 +- .../chat/browser/chatOutputItemRenderer.ts | 87 +++++++-- .../chatMarkdownContentPart.ts | 182 +++++++++++++++++- .../chatMarkdownContentPart.test.ts | 64 ++++++ .../vscode.proposed.chatOutputRenderer.d.ts | 26 ++- 8 files changed, 365 insertions(+), 27 deletions(-) diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 592f55d370259..f7f74fb4ed396 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -84,6 +84,9 @@ "viewType": "vscode.chat-mermaid-features.chatOutputItem", "mimeTypes": [ "text/vnd.mermaid" + ], + "codeBlockLanguageIdentifiers": [ + "mermaid" ] } ], diff --git a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts index 08d5b729a2109..0f7f57ab656e3 100644 --- a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts +++ b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts @@ -7,6 +7,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; +import { ILogService } from '../../../platform/log/common/log.js'; import { IChatOutputRendererService } from '../../contrib/chat/browser/chatOutputItemRenderer.js'; import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostChatOutputRendererShape, ExtHostContext, MainThreadChatOutputRendererShape } from '../common/extHost.protocol.js'; @@ -24,6 +25,7 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre extHostContext: IExtHostContext, private readonly _mainThreadWebview: MainThreadWebviews, @IChatOutputRendererService private readonly _rendererService: IChatOutputRendererService, + @ILogService private readonly _logService: ILogService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatOutputRenderer); @@ -37,22 +39,30 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre } $registerChatOutputRenderer(viewType: string, extensionId: ExtensionIdentifier, extensionLocation: UriComponents): void { - this._rendererService.registerRenderer(viewType, { - renderOutputPart: async (mime, data, webview, token) => { + const existingRegistration = this.registeredRenderers.get(viewType); + if (existingRegistration) { + this._logService.warn(`Re-registering chat output renderer for view type '${viewType}' from extension '${extensionId.value}'.`); + existingRegistration.dispose(); + } + + const disposable = this._rendererService.registerRenderer(viewType, { + renderOutputPart: async (mime, data, webview, context, token) => { const webviewHandle = `chat-output-${++this._webviewHandlePool}`; this._mainThreadWebview.addWebview(webviewHandle, webview, { serializeBuffersForPostMessage: true, }); - return this._proxy.$renderChatOutput(viewType, mime, VSBuffer.wrap(data), webviewHandle, token); + return this._proxy.$renderChatOutput(viewType, mime, VSBuffer.wrap(data), webviewHandle, context, token); }, }, { extension: { id: extensionId, location: URI.revive(extensionLocation) } }); + this.registeredRenderers.set(viewType, disposable); } $unregisterChatOutputRenderer(viewType: string): void { this.registeredRenderers.get(viewType)?.dispose(); + this.registeredRenderers.delete(viewType); } } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0d26fe9292661..e5742089332a9 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1894,8 +1894,14 @@ export interface MainThreadChatOutputRendererShape extends IDisposable { $unregisterChatOutputRenderer(viewType: string): void; } +export interface IChatOutputRenderContextDto { + readonly codeBlockContext?: { + readonly languageIdentifier: string; + }; +} + export interface ExtHostChatOutputRendererShape { - $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise; + $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, context: IChatOutputRenderContextDto, token: CancellationToken): Promise; } export interface MainThreadProfileContentHandlersShape { diff --git a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts index 444faf9e46f9b..8ecaa31b69a5e 100644 --- a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts +++ b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts @@ -5,7 +5,7 @@ import type * as vscode from 'vscode'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { ExtHostChatOutputRendererShape, IMainContext, MainContext, MainThreadChatOutputRendererShape } from './extHost.protocol.js'; +import { ExtHostChatOutputRendererShape, type IChatOutputRenderContextDto, IMainContext, MainContext, MainThreadChatOutputRendererShape } from './extHost.protocol.js'; import { Disposable } from './extHostTypes.js'; import { ExtHostWebviews } from './extHostWebview.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; @@ -41,7 +41,7 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape }); } - async $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise { + async $renderChatOutput(viewType: string, mime: string, valueData: VSBuffer, webviewHandle: string, context: IChatOutputRenderContextDto, token: CancellationToken): Promise { const entry = this._renderers.get(viewType); if (!entry) { throw new Error(`No chat output renderer registered for: ${viewType}`); @@ -52,6 +52,6 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape webview: extHostWebview, onDidDispose: extHostWebview._onDidDispose, }); - return entry.renderer.renderChatOutput(Object.freeze({ mime, value: valueData.buffer }), chatOutputWebview, {}, token); + return entry.renderer.renderChatOutput(Object.freeze({ mime, value: valueData.buffer }), chatOutputWebview, Object.freeze(context), token); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts index 9e150d731cc18..68500fd673737 100644 --- a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts @@ -12,6 +12,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { IJSONSchema, TypeFromJsonSchema } from '../../../../base/common/jsonSchema.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; +import { equalsIgnoreCase } from '../../../../base/common/strings.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import * as nls from '../../../../nls.js'; @@ -23,7 +24,13 @@ import { IExtensionService, isProposedApiEnabled } from '../../../services/exten import { ExtensionsRegistry, IExtensionPointUser } from '../../../services/extensions/common/extensionsRegistry.js'; export interface IChatOutputItemRenderer { - renderOutputPart(mime: string, data: Uint8Array, webview: IWebview, token: CancellationToken): Promise; + renderOutputPart(mime: string, data: Uint8Array, webview: IWebview, context: IChatOutputRenderContext, token: CancellationToken): Promise; +} + +export interface IChatOutputRenderContext { + readonly codeBlockContext?: { + readonly languageIdentifier: string; + }; } interface RegisterOptions { @@ -38,9 +45,13 @@ export const IChatOutputRendererService = createDecorator; + + renderCodeBlock(languageIdentifier: string, data: Uint8Array, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise; } export interface RenderedOutputPart extends IDisposable { @@ -51,10 +62,15 @@ export interface RenderedOutputPart extends IDisposable { } interface RenderOutputPartWebviewOptions { + readonly title?: string; readonly origin?: string; readonly webviewState?: string; } +interface ContributionEntry { + readonly mimes: readonly string[]; + readonly codeBlockLanguageIdentifiers: readonly string[]; +} interface RendererEntry { readonly viewType: string; @@ -65,9 +81,7 @@ interface RendererEntry { export class ChatOutputRendererService extends Disposable implements IChatOutputRendererService { _serviceBrand: undefined; - private readonly _contributions = new Map(); + private readonly _contributions = new Map(); private readonly _renderers = new Map(); @@ -92,8 +106,12 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput }; } + hasCodeBlockRenderer(languageIdentifier: string): boolean { + return Array.from(this._contributions.values()).some(value => value.codeBlockLanguageIdentifiers.some(identifier => equalsIgnoreCase(identifier, languageIdentifier))); + } + async renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise { - const rendererData = await this.getRenderer(mime, token); + const rendererData = await this.getRendererForMime(mime, token); if (token.isCancellationRequested) { throw new CancellationError(); } @@ -102,10 +120,28 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput throw new Error(`No renderer registered found for mime type: ${mime}`); } + return this.doRenderOutputPart(rendererData, mime, data, {}, parent, webviewOptions, token); + } + + async renderCodeBlock(languageIdentifier: string, data: Uint8Array, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise { + const rendererData = await this.getRendererForCodeBlock(languageIdentifier, token); + if (token.isCancellationRequested) { + throw new CancellationError(); + } + + if (!rendererData) { + throw new Error(`No renderer registered found for code block language identifier: ${languageIdentifier}`); + } + + return this.doRenderOutputPart(rendererData, 'text/x-vscode-chat-code-block', data, { codeBlockContext: { languageIdentifier } }, parent, webviewOptions, token); + } + + private async doRenderOutputPart(rendererData: RendererEntry, mime: string, data: Uint8Array, context: IChatOutputRenderContext, parent: HTMLElement, webviewOptions: RenderOutputPartWebviewOptions, token: CancellationToken): Promise { + const store = new DisposableStore(); const webview = store.add(this._webviewService.createWebviewElement({ - title: '', + title: webviewOptions.title ?? '', origin: webviewOptions.origin ?? generateUuid(), providedViewType: rendererData.viewType, options: { @@ -132,7 +168,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput } webview.mountTo(parent, getWindow(parent)); - await rendererData.renderer.renderOutputPart(mime, data, webview, token); + await rendererData.renderer.renderOutputPart(mime, data, webview, context, token); return { get webview() { return webview; }, @@ -146,10 +182,18 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput }; } - private async getRenderer(mime: string, token: CancellationToken): Promise { + private async getRendererForMime(mime: string, token: CancellationToken): Promise { + return this.getRenderer(value => value.mimes.some(m => matchesMimeType(m, [mime])), token); + } + + private async getRendererForCodeBlock(languageIdentifier: string, token: CancellationToken): Promise { + return this.getRenderer(value => value.codeBlockLanguageIdentifiers.some(identifier => equalsIgnoreCase(identifier, languageIdentifier)), token); + } + + private async getRenderer(matches: (value: ContributionEntry) => boolean, token: CancellationToken): Promise { await raceCancellationError(this._extensionService.whenInstalledExtensionsRegistered(), token); for (const [id, value] of this._contributions) { - if (value.mimes.some(m => matchesMimeType(m, [mime]))) { + if (matches(value)) { await raceCancellationError(this._extensionService.activateByEvent(`onChatOutputRenderer:${id}`), token); const rendererData = this._renderers.get(id); if (rendererData) { @@ -174,8 +218,16 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput continue; } + const mimeTypes = contribution.mimeTypes ?? []; + const codeBlockLanguageIdentifiers = contribution.codeBlockLanguageIdentifiers ?? []; + if (!mimeTypes.length && !codeBlockLanguageIdentifiers.length) { + extension.collector.error(`Chat output renderer with view type '${contribution.viewType}' must specify at least one mime type or code block language identifier`); + continue; + } + this._contributions.set(contribution.viewType, { - mimes: contribution.mimeTypes, + mimes: mimeTypes, + codeBlockLanguageIdentifiers, }); } } @@ -185,7 +237,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput const chatOutputRendererContributionSchema = { type: 'object', additionalProperties: false, - required: ['viewType', 'mimeTypes'], + required: ['viewType'], properties: { viewType: { type: 'string', @@ -194,6 +246,15 @@ const chatOutputRendererContributionSchema = { mimeTypes: { type: 'array', description: nls.localize('chatOutputRenderer.mimeTypes', 'MIME types that this renderer can handle'), + uniqueItems: true, + items: { + type: 'string' + } + }, + codeBlockLanguageIdentifiers: { + type: 'array', + description: nls.localize('chatOutputRenderer.codeBlockLanguageIdentifiers', 'Code block language identifiers that this renderer can handle'), + uniqueItems: true, items: { type: 'string' } @@ -211,7 +272,7 @@ const chatOutputRenderContributionPoint = ExtensionsRegistry.registerExtensionPo } }, jsonSchema: { - description: nls.localize('vscode.extension.contributes.chatOutputRenderer', 'Contributes a renderer for specific MIME types in chat outputs'), + description: nls.localize('vscode.extension.contributes.chatOutputRenderer', 'Contributes a renderer for specific MIME types and code block language identifiers in chat outputs'), type: 'array', items: chatOutputRendererContributionSchema, } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 056d9177a3100..934b268c1cbb1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -13,7 +13,9 @@ import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollba import { wrapTablesWithScrollable } from './chatMarkdownTableScrolling.js'; import { coalesce } from '../../../../../../base/common/arrays.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { isCancellationError } from '../../../../../../base/common/errors.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -23,6 +25,7 @@ import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { isLocation, type SymbolTag } from '../../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; @@ -51,7 +54,8 @@ import { IChatProgressRenderableResponseContent } from '../../../common/model/ch import { IChatContentInlineReference, IChatMarkdownContent, IChatService, IChatUndoStop } from '../../../common/chatService/chatService.js'; import { isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { IChatCodeBlockInfo } from '../../chat.js'; +import { IChatCodeBlockInfo, IChatWidgetService } from '../../chat.js'; +import { IChatOutputRendererService, type RenderedOutputPart } from '../../chatOutputItemRenderer.js'; import { allowedChatMarkdownHtmlTags } from '../chatContentMarkdownRenderer.js'; import { IMarkdownDiffBlockData, MarkdownDiffBlockPart, parseUnifiedDiff } from './chatDiffBlockPart.js'; import { ChatEditingActionContext } from '../../chatEditing/chatEditingActions.js'; @@ -62,6 +66,7 @@ import { IDisposableReference } from './chatCollections.js'; import { EditorPool } from './chatContentCodePools.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js'; +import { ChatProgressSubPart } from './chatProgressContentPart.js'; import { IncrementalDOMMorpher } from './chatIncrementalRendering/chatIncrementalRendering.js'; import './media/chatMarkdownPart.css'; @@ -102,7 +107,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP */ readonly onDidChangeDiff: Event = this._onDidChangeDiff.event; - private readonly allRefs: IDisposableReference[] = []; + private readonly allRefs: IDisposableReference[] = []; private readonly _codeblocks: IMarkdownPartCodeBlockInfo[] = []; public get codeblocks(): IChatCodeBlockInfo[] { @@ -128,6 +133,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP @IConfigurationService configurationService: IConfigurationService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IAiEditTelemetryService private readonly aiEditTelemetryService: IAiEditTelemetryService, + @IChatOutputRendererService private readonly chatOutputRendererService: IChatOutputRendererService, ) { super(); @@ -218,7 +224,11 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP fillInIncompleteTokens, codeBlockRendererSync: (languageId, text, raw) => { const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || codeblockHasClosingBackticks(raw); - if ((!text || (text.startsWith(' { + const codeBlock = this.instantiationService.createInstance( + ChatOutputCodeBlockPart, + identifier, + text, + codeBlockIndex, + context, + isComplete, + () => this._onDidChangeHeight.fire() + ); + const ref: IDisposableReference = { + object: codeBlock, + isStale: () => false, + dispose: () => codeBlock.dispose() + }; + this.allRefs.push(ref); + return ref; + } + private fireAggregatedDiff(): void { let totalAdded = 0; let totalRemoved = 0; @@ -516,6 +568,8 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP this.allRefs.forEach((ref, index) => { if (ref.object instanceof CodeBlockPart) { ref.object.layout(width); + } else if (ref.object instanceof ChatOutputCodeBlockPart) { + ref.object.layout(width); } else if (ref.object instanceof MarkdownDiffBlockPart) { ref.object.layout(width); } else if (ref.object instanceof CollapsedCodeBlock) { @@ -531,7 +585,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP onDidRemount(): void { for (const ref of this.allRefs) { - if (ref.object instanceof CodeBlockPart) { + if (ref.object instanceof CodeBlockPart || ref.object instanceof ChatOutputCodeBlockPart) { ref.object.onDidRemount(); } } @@ -613,6 +667,126 @@ export function codeblockHasClosingBackticks(str: string): boolean { return !!str.match(/\n```+$/); } +class ChatOutputCodeBlockPart extends Disposable { + + readonly element: HTMLElement; + + private readonly _disposeCts = this._register(new CancellationTokenSource()); + private readonly _renderedOutputPart = this._register(new MutableDisposable()); + + constructor( + identifier: string, + text: string, + codeBlockIndex: number, + private readonly context: IChatContentPartRenderContext, + isComplete: boolean, + private readonly onDidChangeHeight: () => void, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatOutputRendererService private readonly chatOutputRendererService: IChatOutputRendererService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + ) { + super(); + + const title = localize('chat.renderedCodeBlockLabel', "Rendered code block {0}", codeBlockIndex + 1); + this.element = $('.interactive-result-code-block.chat-output-code-block.tool-output-part'); + this.element.tabIndex = -1; + this.element.ariaLabel = title; + + const parent = $('.webview-output'); + parent.style.maxHeight = '80vh'; + parent.style.minHeight = '38px'; + this.element.appendChild(parent); + + const progressMessage = $('span'); + progressMessage.textContent = localize('chat.codeBlockOutputRendering', "Rendering code block..."); + const progressPart = this._register(this.instantiationService.createInstance(ChatProgressSubPart, progressMessage, ThemeIcon.modify(Codicon.loading, 'spin'), undefined)); + parent.appendChild(progressPart.domNode); + if (!isComplete) { + this.onDidChangeHeight(); + return; + } + + this.chatOutputRendererService.renderCodeBlock(identifier, new TextEncoder().encode(text), parent, { origin: generateUuid(), title }, this._disposeCts.token).then(renderedItem => { + if (this._disposeCts.token.isCancellationRequested) { + renderedItem.dispose(); + return; + } + + this._renderedOutputPart.value = renderedItem; + progressPart.domNode.remove(); + parent.style.minHeight = ''; + this.onDidChangeHeight(); + + this._register(renderedItem.onDidChangeHeight(() => this.onDidChangeHeight())); + this._register(renderedItem.webview.onDidWheel(e => { + this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.delegateScrollFromMouseWheelEvent({ + ...e, + preventDefault: () => { }, + stopPropagation: () => { } + }); + })); + + this._register(this.context.onDidChangeVisibility(visible => { + if (visible) { + renderedItem.reinitialize(); + } + })); + }, error => { + if (isCancellationError(error)) { + return; + } + + console.error('Error rendering chat code block:', error); + progressPart.domNode.replaceWith(this.renderError(error)); + parent.style.minHeight = ''; + this.onDidChangeHeight(); + }); + } + + override dispose(): void { + this._disposeCts.dispose(true); + super.dispose(); + } + + layout(width: number): void { + this.element.style.maxWidth = `${width}px`; + } + + onDidRemount(): void { + this._renderedOutputPart.value?.reinitialize(); + } + + focus(): void { + const webview = this._renderedOutputPart.value?.webview; + if (webview) { + webview.focus(); + } else { + this.element.focus(); + } + } + + private renderError(error: Error): HTMLElement { + const errorNode = $('.output-error'); + + const errorHeaderNode = $('.output-error-header'); + dom.append(errorNode, errorHeaderNode); + + const iconElement = $('div'); + iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.error)); + errorHeaderNode.append(iconElement); + + const errorTitleNode = $('.output-error-title'); + errorTitleNode.textContent = localize('chat.codeBlockOutputError', "Error rendering the code block"); + errorHeaderNode.append(errorTitleNode); + + const errorMessageNode = $('.output-error-details'); + errorMessageNode.textContent = error?.message || String(error); + errorNode.append(errorMessageNode); + + return errorNode; + } +} + export class CollapsedCodeBlock extends Disposable { readonly element: HTMLElement; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts index 36d47ed7be88e..e9b7070cec8ea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts @@ -22,6 +22,7 @@ import { IChatContentPartRenderContext } from '../../../../browser/widget/chatCo import { ChatMarkdownContentPart } from '../../../../browser/widget/chatContentParts/chatMarkdownContentPart.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { CodeBlockPart, ICodeBlockData } from '../../../../browser/widget/chatContentParts/codeBlockPart.js'; +import { IChatOutputRendererService, type RenderedOutputPart } from '../../../../browser/chatOutputItemRenderer.js'; import { IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; import { IChatContentInlineReference } from '../../../../common/chatService/chatService.js'; import { ChatConfiguration } from '../../../../common/constants.js'; @@ -39,6 +40,7 @@ suite('ChatMarkdownContentPart', () => { /** Data captured from each CodeBlockPart.render() call */ const renderedCodeBlocks: ICodeBlockData[] = []; + const renderedCodeBlockOutputs: { identifier: string; text: string }[] = []; function createMockEditorPool(): EditorPool { return { @@ -132,6 +134,7 @@ suite('ChatMarkdownContentPart', () => { disposables = store.add(new DisposableStore()); instantiationService = workbenchInstantiationService(undefined, disposables); renderedCodeBlocks.length = 0; + renderedCodeBlockOutputs.length = 0; // Seed configuration values needed by ChatEditorOptions const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; @@ -171,6 +174,25 @@ suite('ChatMarkdownContentPart', () => { handleCodeRejected: () => { }, }); + instantiationService.stub(IChatOutputRendererService, { + _serviceBrand: undefined, + registerRenderer: () => ({ dispose: () => { } }), + hasCodeBlockRenderer: identifier => identifier.toLowerCase() === 'mermaid', + renderOutputPart: async () => { throw new Error('Unexpected output render'); }, + renderCodeBlock: async (identifier, data) => { + renderedCodeBlockOutputs.push({ identifier, text: new TextDecoder().decode(data) }); + return { + webview: { + focus: () => { }, + onDidWheel: Event.None, + } as RenderedOutputPart['webview'], + onDidChangeHeight: Event.None, + reinitialize: () => { }, + dispose: () => { }, + }; + }, + }); + // Stub view descriptor service instantiationService.stub(IViewDescriptorService, { onDidChangeLocation: Event.None, @@ -209,6 +231,48 @@ suite('ChatMarkdownContentPart', () => { assert.strictEqual(renderedCodeBlocks[0].languageId, 'javascript'); }); + test('renders complete code block with contributed chat output renderer', () => { + const part = createMarkdownPart('```mermaid\ngraph TD\n```'); + + assert.strictEqual(part.codeblocks.length, 1); + assert.strictEqual(part.codeblocks[0].languageId, 'mermaid'); + assert.strictEqual(renderedCodeBlocks.length, 0); + assert.deepStrictEqual(renderedCodeBlockOutputs, [{ identifier: 'mermaid', text: 'graph TD' }]); + assert.ok(part.domNode.querySelector('.chat-output-code-block')); + }); + + test('renders complete code block with contributed chat output renderer case-insensitively', () => { + const part = createMarkdownPart('```Mermaid\ngraph TD\n```'); + + assert.strictEqual(part.codeblocks.length, 1); + assert.strictEqual(part.codeblocks[0].languageId, 'Mermaid'); + assert.strictEqual(renderedCodeBlocks.length, 0); + assert.deepStrictEqual(renderedCodeBlockOutputs, [{ identifier: 'Mermaid', text: 'graph TD' }]); + assert.ok(part.domNode.querySelector('.chat-output-code-block')); + }); + + test('does not render initial incomplete code fence', () => { + const ctx = createRenderContext(false); + const part = createMarkdownPart('```', ctx); + + assert.strictEqual(part.codeblocks.length, 0); + assert.strictEqual(renderedCodeBlocks.length, 0); + assert.strictEqual(renderedCodeBlockOutputs.length, 0); + assert.strictEqual(part.domNode.querySelector('.interactive-result-code-block'), null); + }); + + test('shows pending chat output renderer for incomplete code block', () => { + const ctx = createRenderContext(false); + const part = createMarkdownPart('```mermaid\ngraph TD', ctx); + + assert.strictEqual(renderedCodeBlockOutputs.length, 0); + assert.strictEqual(renderedCodeBlocks.length, 0); + assert.strictEqual(part.codeblocks.length, 1); + assert.strictEqual(part.codeblocks[0].languageId, 'mermaid'); + assert.ok(part.domNode.querySelector('.chat-output-code-block')); + assert.ok(part.domNode.textContent?.includes('Rendering code block')); + }); + test('renders multiple code blocks with correct indices', () => { const part = createMarkdownPart( 'Some text\n```python\nprint("a")\n```\nMore text\n```typescript\nconst x = 1;\n```' diff --git a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts index 839ce0bb41ad7..16a26a43bed02 100644 --- a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts +++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts @@ -59,6 +59,25 @@ declare module 'vscode' { readonly onDidDispose: Event; } + /** + * Additional context for rendering chat output. + */ + export interface ChatOutputRenderContext { + /** + * Context when a code block is being rendered. + * + * This is set when a renderer contributed through `chatOutputRenderers` renders + * a completed fenced code block whose identifier matches one of the contributed + * `codeBlockLanguageIdentifiers`. + */ + readonly codeBlockContext?: { + /** + * The language identifier of the code block being rendered. + */ + readonly languageIdentifier: string; + }; + } + export interface ChatOutputRenderer { /** * Given an output, render it into the provided webview. @@ -72,7 +91,7 @@ declare module 'vscode' { * * @returns A promise that resolves when the webview has been initialized and is ready to be presented to the user. */ - renderChatOutput(data: ChatOutputDataItem, webview: ChatOutputWebview, ctx: {}, token: CancellationToken): Thenable; + renderChatOutput(data: ChatOutputDataItem, webview: ChatOutputWebview, ctx: ChatOutputRenderContext, token: CancellationToken): Thenable; } export namespace chat { @@ -84,10 +103,11 @@ declare module 'vscode' { * * ```json * "contributes": { - * "chatOutputRenderer": [ + * "chatOutputRenderers": [ * { * "viewType": "myExt.myChatOutputRenderer", - * "mimeTypes": ["application/your-mime-type"] + * "mimeTypes": ["application/your-mime-type"], + * "codeBlockLanguageIdentifiers": ["mermaid"] * } * ] * } From 06115d9e8fbc5bfae917509a73cf7d394ae1f2d9 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 12 May 2026 16:33:15 -0700 Subject: [PATCH 05/27] Add improved markdown front matter rendering Fixes #316140 Let's try with `table` as the default initially but we can switch to `hide` is we prefer --- .../media/markdown.css | 44 ++++ .../package-lock.json | 24 ++- .../markdown-language-features/package.json | 20 +- .../package.nls.json | 4 + .../extensions/yamlPreamble/yamlPreamble.ts | 200 ++++++++++++++++++ .../src/markdownEngine.ts | 16 +- .../src/preview/previewConfig.ts | 2 + .../src/test/engine.test.ts | 69 ++++++ .../src/typings/ref.d.ts | 6 - .../src/util/dom.ts | 9 + 10 files changed, 365 insertions(+), 29 deletions(-) create mode 100644 extensions/markdown-language-features/src/extensions/yamlPreamble/yamlPreamble.ts delete mode 100644 extensions/markdown-language-features/src/typings/ref.d.ts diff --git a/extensions/markdown-language-features/media/markdown.css b/extensions/markdown-language-features/media/markdown.css index 5a02627888dd7..53179e10f816a 100644 --- a/extensions/markdown-language-features/media/markdown.css +++ b/extensions/markdown-language-features/media/markdown.css @@ -453,3 +453,47 @@ pre { .vscode-dark td { border-color: rgba(255, 255, 255, 0.18); } + +/* Front matter rendering */ +table.frontmatter { + margin-bottom: 16px; + border-collapse: collapse; +} + +table.frontmatter th, +table.frontmatter td { + padding: 6px 13px; + border: 1px solid var(--vscode-widget-border, rgba(127, 127, 127, 0.35)); + text-align: left; + vertical-align: top; +} + +table.frontmatter th { + font-weight: 600; + white-space: nowrap; +} + +table.frontmatter td > ul { + margin: 0; + padding-left: 1.2em; +} + +pre.frontmatter { + margin-bottom: 16px; +} + +.frontmatter-error { + margin-bottom: 16px; + padding: 8px 13px; + border-left: 4px solid var(--vscode-editorError-foreground, #f48771); + background: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.1)); + color: var(--vscode-editorError-foreground, #f48771); +} + +.frontmatter-error pre { + margin: 6px 0 0; + white-space: pre-wrap; + color: inherit; + background: transparent; + padding: 0; +} diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 15fed36ad6319..15c5980ba3864 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -13,14 +13,14 @@ "dompurify": "^3.4.1", "highlight.js": "^11.8.0", "markdown-it": "^12.3.2", - "markdown-it-front-matter": "^0.2.4", "morphdom": "^2.7.7", "picomatch": "^2.3.2", "punycode": "^2.3.1", "vscode-languageclient": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-markdown-languageserver": "0.5.0-alpha.15", - "vscode-uri": "^3.0.3" + "vscode-uri": "^3.0.3", + "yaml": "^2.8.3" }, "devDependencies": { "@types/dompurify": "^3.0.5", @@ -494,11 +494,6 @@ "markdown-it": "bin/markdown-it.js" } }, - "node_modules/markdown-it-front-matter": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/markdown-it-front-matter/-/markdown-it-front-matter-0.2.4.tgz", - "integrity": "sha512-25GUs0yjS2hLl8zAemVndeEzThB1p42yxuDEKbd4JlL3jiz+jsm6e56Ya8B0VREOkNxLYB4TTwaoPJ3ElMmW+w==" - }, "node_modules/markdown-it/node_modules/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", @@ -730,6 +725,21 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index cd2e94bbb48b7..d8232ce221927 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -771,6 +771,22 @@ "%configuration.markdown.preview.openMarkdownLinks.inPreview%", "%configuration.markdown.preview.openMarkdownLinks.inEditor%" ] + }, + "markdown.preview.frontMatter": { + "type": "string", + "default": "table", + "scope": "resource", + "markdownDescription": "%configuration.markdown.preview.frontMatter.description%", + "enum": [ + "hide", + "codeBlock", + "table" + ], + "enumDescriptions": [ + "%configuration.markdown.preview.frontMatter.hide%", + "%configuration.markdown.preview.frontMatter.codeBlock%", + "%configuration.markdown.preview.frontMatter.table%" + ] } } }, @@ -860,14 +876,14 @@ "dompurify": "^3.4.1", "highlight.js": "^11.8.0", "markdown-it": "^12.3.2", - "markdown-it-front-matter": "^0.2.4", "morphdom": "^2.7.7", "picomatch": "^2.3.2", "punycode": "^2.3.1", "vscode-languageclient": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-markdown-languageserver": "0.5.0-alpha.15", - "vscode-uri": "^3.0.3" + "vscode-uri": "^3.0.3", + "yaml": "^2.8.3" }, "devDependencies": { "@types/dompurify": "^3.0.5", diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index dc8f65048c998..b4ac4c7f58a78 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -37,6 +37,10 @@ "configuration.markdown.preview.openMarkdownLinks.description": "Controls how links to other Markdown files in the Markdown preview should be opened.", "configuration.markdown.preview.openMarkdownLinks.inEditor": "Try to open links in the editor.", "configuration.markdown.preview.openMarkdownLinks.inPreview": "Try to open links in the Markdown preview.", + "configuration.markdown.preview.frontMatter.description": "Controls how YAML front matter (delimited by `---`) at the start of a Markdown file is rendered in the preview.", + "configuration.markdown.preview.frontMatter.hide": "Do not render front matter.", + "configuration.markdown.preview.frontMatter.codeBlock": "Render front matter as a code block.", + "configuration.markdown.preview.frontMatter.table": "Render front matter as a table of keys and values.", "configuration.markdown.links.openLocation.description": "Controls where links in Markdown files should be opened.", "configuration.markdown.links.openLocation.currentGroup": "Open links in the active editor group.", "configuration.markdown.links.openLocation.beside": "Open links beside the active editor.", diff --git a/extensions/markdown-language-features/src/extensions/yamlPreamble/yamlPreamble.ts b/extensions/markdown-language-features/src/extensions/yamlPreamble/yamlPreamble.ts new file mode 100644 index 0000000000000..417386e232e40 --- /dev/null +++ b/extensions/markdown-language-features/src/extensions/yamlPreamble/yamlPreamble.ts @@ -0,0 +1,200 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type MarkdownIt from 'markdown-it'; +import type Token from 'markdown-it/lib/token.mjs'; +import * as vscode from 'vscode'; +import * as yaml from 'yaml'; +import { escapeHtml } from '../../util/dom'; + +export type FrontMatterRenderStyle = 'hide' | 'codeBlock' | 'table'; + +const FRONT_MATTER_TOKEN = 'front_matter'; +const MARKER = '---'; + +interface IFrontMatterMeta { + readonly content: string; +} + +/** + * Extends a `markdown-it` instance with parsing and rendering support for YAML + * front matter at the start of a Markdown document. + * + * Front matter is delimited by lines containing only `---`. How (or whether) the parsed + * front matter is rendered in the preview is controlled by the `markdown.preview.frontMatter` + * setting. + */ +export function extendMarkdownIt(md: MarkdownIt): MarkdownIt { + md.block.ruler.before('fence', FRONT_MATTER_TOKEN, frontMatterRule, { + alt: ['paragraph', 'reference', 'blockquote', 'list'] + }); + + md.renderer.rules[FRONT_MATTER_TOKEN] = renderFrontMatter; + + return md; +} + +const frontMatterRule = (state: MarkdownIt.StateBlock, startLine: number, endLine: number, silent: boolean): boolean => { + if (startLine !== 0 || state.tShift[startLine] !== 0) { + return false; + } + + const firstLineStart = state.bMarks[startLine]; + const firstLineEnd = state.eMarks[startLine]; + const firstLine = state.src.slice(firstLineStart, firstLineEnd).replace(/\s+$/, ''); + + if (firstLine !== MARKER) { + return false; + } + + let nextLine = startLine + 1; + let foundEnd = false; + for (; nextLine < endLine; nextLine++) { + if (state.tShift[nextLine] !== 0) { + continue; + } + const lineStart = state.bMarks[nextLine]; + const lineEnd = state.eMarks[nextLine]; + const line = state.src.slice(lineStart, lineEnd).replace(/\s+$/, ''); + if (line === MARKER) { + foundEnd = true; + break; + } + } + + if (!foundEnd) { + return false; + } + + if (silent) { + return true; + } + + const contentStart = state.bMarks[startLine + 1]; + const contentEnd = state.bMarks[nextLine]; + const rawContent = state.src.slice(contentStart, contentEnd).replace(/\n$/, ''); + + const token = state.push(FRONT_MATTER_TOKEN, '', 0); + token.block = true; + token.hidden = false; + token.markup = MARKER; + token.map = [startLine, nextLine + 1]; + const meta: IFrontMatterMeta = { content: rawContent }; + token.meta = meta; + + state.line = nextLine + 1; + return true; +}; + +function renderFrontMatter(tokens: Token[], idx: number, options: MarkdownIt.Options, env: unknown): string { + const meta = tokens[idx].meta as IFrontMatterMeta | undefined; + if (!meta) { + return ''; + } + + const currentDocument = (env as { currentDocument?: vscode.Uri } | undefined)?.currentDocument; + const style = getFrontMatterRenderStyle(currentDocument); + + switch (style) { + case 'codeBlock': + return renderAsCodeBlock(meta, options); + case 'table': + return renderAsTable(meta); + case 'hide': + default: + return ''; + } +} + +function getFrontMatterRenderStyle(resource: vscode.Uri | undefined): FrontMatterRenderStyle { + const config = vscode.workspace.getConfiguration('markdown', resource ?? null); + const value = config.get('preview.frontMatter', 'table'); + switch (value) { + case 'codeBlock': + case 'table': + case 'hide': + return value; + default: + return 'table'; + } +} + +function renderAsCodeBlock(meta: IFrontMatterMeta, options: MarkdownIt.Options): string { + let highlighted: string | undefined; + if (typeof options.highlight === 'function') { + try { + highlighted = options.highlight(meta.content, 'yaml', '') || undefined; + } catch { + highlighted = undefined; + } + } + if (highlighted?.startsWith('${body}\n`; +} + +function renderAsTable(meta: IFrontMatterMeta): string { + const result = parseEntries(meta); + if (result.error !== undefined) { + return renderError(result.error); + } + if (!result.entries.length) { + return ''; + } + const rows = result.entries.map(([key, value]) => + `${escapeHtml(key)}${formatValueHtml(value)}` + ).join(''); + return `${rows}
\n`; +} + +function renderError(message: string): string { + const label = vscode.l10n.t('Failed to parse front matter'); + return `\n`; +} + +interface IParseResult { + readonly entries: readonly [string, unknown][]; + readonly error?: string; +} + +function parseEntries(meta: IFrontMatterMeta): IParseResult { + try { + const parsed = yaml.parse(meta.content); + if (parsed === null || parsed === undefined) { + return { entries: [] }; + } + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + return { entries: [['', parsed]] }; + } + return { entries: Object.entries(parsed as Record) }; + } catch (e) { + return { entries: [], error: e instanceof Error ? e.message : String(e) }; + } +} + +function formatValueHtml(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value)) { + if (!value.length) { + return ''; + } + return `
    ${value.map(v => `
  • ${formatValueHtml(v)}
  • `).join('')}
`; + } + if (typeof value === 'object') { + return `${escapeHtml(yaml.stringify(value).trimEnd())}`; + } + return escapeHtml(formatScalar(value)); +} + +function formatScalar(value: unknown): string { + if (value instanceof Date) { + return value.toISOString(); + } + return String(value); +} diff --git a/extensions/markdown-language-features/src/markdownEngine.ts b/extensions/markdown-language-features/src/markdownEngine.ts index 4ed3186c3807d..d1aacf9a8b171 100644 --- a/extensions/markdown-language-features/src/markdownEngine.ts +++ b/extensions/markdown-language-features/src/markdownEngine.ts @@ -5,6 +5,7 @@ import type MarkdownIt from 'markdown-it'; import * as vscode from 'vscode'; +import { extendMarkdownIt as extendMarkdownItWithFrontMatter } from './extensions/yamlPreamble/yamlPreamble'; import { ILogger } from './logging'; import { MarkdownContributionProvider } from './markdownExtensions'; import { MarkdownPreviewConfiguration } from './preview/previewConfig'; @@ -144,20 +145,7 @@ export class MarkdownItEngine implements IMdParser { } } - const frontMatterPlugin = await import('markdown-it-front-matter'); - // Extract rules from front matter plugin and apply at a lower precedence - let fontMatterRule: any; - frontMatterPlugin.default({ - block: { - ruler: { - before: (_id: any, _id2: any, rule: any) => { fontMatterRule = rule; } - } - } - }, () => { /* noop */ }); - - md.block.ruler.before('fence', 'front_matter', fontMatterRule, { - alt: ['paragraph', 'reference', 'blockquote', 'list'] - }); + md = extendMarkdownItWithFrontMatter(md); this.#addImageRenderer(md); this.#addFencedRenderer(md); diff --git a/extensions/markdown-language-features/src/preview/previewConfig.ts b/extensions/markdown-language-features/src/preview/previewConfig.ts index ac240b97eb71c..821bbb7840595 100644 --- a/extensions/markdown-language-features/src/preview/previewConfig.ts +++ b/extensions/markdown-language-features/src/preview/previewConfig.ts @@ -17,6 +17,7 @@ export class MarkdownPreviewConfiguration { public readonly previewLineBreaks: boolean; public readonly previewLinkify: boolean; public readonly previewTypographer: boolean; + public readonly previewFrontMatter: string; public readonly doubleClickToSwitchToEditor: boolean; public readonly scrollEditorWithPreview: boolean; @@ -46,6 +47,7 @@ export class MarkdownPreviewConfiguration { this.previewLineBreaks = !!markdownConfig.get('preview.breaks', false); this.previewLinkify = !!markdownConfig.get('preview.linkify', true); this.previewTypographer = !!markdownConfig.get('preview.typographer', false); + this.previewFrontMatter = markdownConfig.get('preview.frontMatter', 'table'); this.doubleClickToSwitchToEditor = !!markdownConfig.get('preview.doubleClickToSwitchToEditor', true); this.markEditorSelection = !!markdownConfig.get('preview.markEditorSelection', true); diff --git a/extensions/markdown-language-features/src/test/engine.test.ts b/extensions/markdown-language-features/src/test/engine.test.ts index cd3322f704ae9..b9f980e19fe43 100644 --- a/extensions/markdown-language-features/src/test/engine.test.ts +++ b/extensions/markdown-language-features/src/test/engine.test.ts @@ -49,4 +49,73 @@ suite('markdown.engine', () => { assert.deepStrictEqual([...result.containingImages], ['img.png', 'http://example.org/img.png', './img2.png']); }); }); + + suite('front-matter', () => { + const settingName = 'preview.frontMatter'; + const input = '---\ntitle: Hello\n---\n\n# World'; + + let originalValue: string | undefined; + + suiteSetup(() => { + originalValue = vscode.workspace.getConfiguration('markdown').inspect(settingName)?.globalValue; + }); + + suiteTeardown(async () => { + await vscode.workspace.getConfiguration('markdown').update(settingName, originalValue, vscode.ConfigurationTarget.Global); + }); + + async function setStyle(style: string) { + await vscode.workspace.getConfiguration('markdown').update(settingName, style, vscode.ConfigurationTarget.Global); + } + + test('Hides front matter when style is "hide"', async () => { + await setStyle('hide'); + const engine = createNewMarkdownEngine(); + assert.strictEqual( + (await engine.render(input)).html, + '

World

\n' + ); + }); + + test('Renders front matter as a code block when style is "codeBlock"', async () => { + await setStyle('codeBlock'); + const engine = createNewMarkdownEngine(); + const html = (await engine.render(input)).html; + assert.match(html, /]*class="[^"]*frontmatter[^"]*"[^>]*>[\s\S]*<\/pre>/); + assert.ok(html.includes('title'), `Expected front matter content to be rendered. Got: ${html}`); + assert.ok(html.includes('

{ + await setStyle('table'); + const engine = createNewMarkdownEngine(); + assert.strictEqual( + (await engine.render(input)).html, + '
titleHello
\n' + + '

World

\n' + ); + }); + + test('Shows an error when front matter has invalid YAML', async () => { + await setStyle('table'); + const engine = createNewMarkdownEngine(); + const html = (await engine.render('---\nfoo: [unclosed\n---\n\n# Body')).html; + assert.match(html, /
/); + assert.ok(html.includes('

{ + await setStyle('table'); + const engine = createNewMarkdownEngine(); + const html = (await engine.render('# World\n\n---\ntitle: Hello\n---')).html; + assert.ok(!html.includes(''), `Expected no front matter table. Got: ${html}`); + }); + + test('Ignores front matter without a closing delimiter', async () => { + await setStyle('table'); + const engine = createNewMarkdownEngine(); + const html = (await engine.render('---\ntitle: Hello\n\n# World')).html; + assert.ok(!html.includes('
'), `Expected no front matter table. Got: ${html}`); + }); + }); }); diff --git a/extensions/markdown-language-features/src/typings/ref.d.ts b/extensions/markdown-language-features/src/typings/ref.d.ts deleted file mode 100644 index d16ef8dc39701..0000000000000 --- a/extensions/markdown-language-features/src/typings/ref.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'markdown-it-front-matter'; diff --git a/extensions/markdown-language-features/src/util/dom.ts b/extensions/markdown-language-features/src/util/dom.ts index 16c825c68ff57..68cd47cdbf16c 100644 --- a/extensions/markdown-language-features/src/util/dom.ts +++ b/extensions/markdown-language-features/src/util/dom.ts @@ -11,3 +11,12 @@ export function escapeAttribute(value: string | vscode.Uri): string { .replace(/'/g, '''); } +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + From 5284fa1d910b15717acf6283eb774c661e4b2034 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 13 May 2026 14:40:25 +0200 Subject: [PATCH 06/27] sessions: use delegation for active session and force replace on session swap (#316229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the spread-based `IActiveSession` construction with an `ActiveSession` class that delegates all `ISession` property accesses to the underlying session object. This keeps the active session in sync with live session state instead of holding a stale shallow copy. When a session is replaced via `onDidReplaceSession`, pass `force: true` to `setActiveSession` so the active session is always fully rebuilt — even when the session ID remains the same. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsManagementService.ts | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 4331eaaead95f..829e4ac43bba5 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -142,7 +142,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen private onDidReplaceSession(from: ISession, to: ISession): void { if (this._activeSession.get()?.sessionId === from.sessionId) { - this.setActiveSession(to); + this.setActiveSession(to, /* force */ true); } // Always fire the change event so the SessionsList refreshes even when // the user navigated to a different session while the new one was @@ -430,9 +430,9 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._isNewChatInSessionContext.set(true); } - private setActiveSession(session: ISession | undefined): void { + private setActiveSession(session: ISession | undefined, force?: boolean): void { const previousSession = this._activeSession.get(); - if (previousSession?.sessionId === session?.sessionId) { + if (!force && previousSession?.sessionId === session?.sessionId) { return; } @@ -478,10 +478,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen // Create the active chat observable const activeChatObs = observableValue(`activeChat-${session.sessionId}`, initialChat); this._activeChatObservable = activeChatObs; - const activeSession: IActiveSession = { - ...session, - activeChat: activeChatObs, - }; + const activeSession = new ActiveSession(session, activeChatObs); this._activeSession.set(activeSession, undefined); @@ -705,6 +702,43 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } } +/** + * Wraps an {@link ISession} with an active chat observable to form an + * {@link IActiveSession}. Delegates all {@link ISession} property accesses + * to the wrapped session so the active session always reflects the latest + * session state without a stale shallow copy. + */ +class ActiveSession implements IActiveSession { + constructor( + private readonly _session: ISession, + readonly activeChat: IObservable, + ) { } + + get sessionId() { return this._session.sessionId; } + get resource() { return this._session.resource; } + get providerId() { return this._session.providerId; } + get sessionType() { return this._session.sessionType; } + get icon() { return this._session.icon; } + get createdAt() { return this._session.createdAt; } + get workspace() { return this._session.workspace; } + get title() { return this._session.title; } + get updatedAt() { return this._session.updatedAt; } + get status() { return this._session.status; } + get changes() { return this._session.changes; } + get changesets() { return this._session.changesets; } + get modelId() { return this._session.modelId; } + get mode() { return this._session.mode; } + get loading() { return this._session.loading; } + get isArchived() { return this._session.isArchived; } + get isRead() { return this._session.isRead; } + get description() { return this._session.description; } + get lastTurnEnd() { return this._session.lastTurnEnd; } + get chats() { return this._session.chats; } + get mainChat() { return this._session.mainChat; } + get capabilities() { return this._session.capabilities; } + get deduplicationKey() { return this._session.deduplicationKey; } +} + registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); /** From 22aa13fb9a97e49a5f58d2f0736b1410b2908f8a Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 13 May 2026 08:41:04 -0400 Subject: [PATCH 07/27] Remove deprecated token fields (#316230) --- .../src/platform/authentication/common/copilotToken.ts | 9 +-------- .../authentication/test/node/copilotToken.spec.ts | 3 --- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/extensions/copilot/src/platform/authentication/common/copilotToken.ts b/extensions/copilot/src/platform/authentication/common/copilotToken.ts index ae8968f27d406..f20de388488f4 100644 --- a/extensions/copilot/src/platform/authentication/common/copilotToken.ts +++ b/extensions/copilot/src/platform/authentication/common/copilotToken.ts @@ -345,8 +345,6 @@ export interface TokenEnvelope { codesearch: boolean; /** Whether content exclusion (.copilotignore) is enabled. */ copilotignore_enabled: boolean; - /** Whether VS Code electron fetcher v2 is enabled. */ - vsc_electron_fetcher_v2: boolean; // Consent settings /** 'enabled', 'disabled', or 'unconfigured' for public code suggestions. */ @@ -365,8 +363,6 @@ export interface TokenEnvelope { limited_user_reset_date?: number | null; /** Organization tracking IDs if user has org access. */ organization_list?: string[]; - /** Notification to show in editor on successful token retrieval. */ - user_notification?: NotificationEnvelope; } /** @@ -419,7 +415,6 @@ const tokenEnvelopeValidator = vObj({ code_review_enabled: vBoolean(), codesearch: vBoolean(), copilotignore_enabled: vBoolean(), - vsc_electron_fetcher_v2: vBoolean(), public_suggestions: vEnum('enabled', 'disabled', 'unconfigured'), telemetry: vEnum('enabled', 'disabled'), endpoints: vObj({ @@ -434,8 +429,7 @@ const tokenEnvelopeValidator = vObj({ completions: vRequired(vNumber()), })), limited_user_reset_date: vNullable(vNumber()), - organization_list: vArray(vString()), - user_notification: notificationEnvelopeValidator, + organization_list: vArray(vString()) }); const standardErrorEnvelopeValidator = vObj({ @@ -565,7 +559,6 @@ export function createTestExtendedTokenInfo(overrides?: Partial Date: Wed, 13 May 2026 14:41:42 +0200 Subject: [PATCH 08/27] sessions: fix customization harness layer violation by using session types (#316231) Replace the eslint-disabled import of LOCAL_SESSION_ENABLED_SETTING from the providers layer with ISessionsManagementService.getAllSessionTypes(). The local harness is now registered when any provider offers a session type with id 'local', and removed when it disappears. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/customizationHarnessService.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts index 57201bedf435a..258927e65adab 100644 --- a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts +++ b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts @@ -4,20 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, IHarnessDescriptor } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { BUILTIN_STORAGE } from '../common/builtinPromptsStorage.js'; import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -// eslint-disable-next-line local/code-import-patterns -import { LOCAL_SESSION_ENABLED_SETTING } from '../../providers/copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +/** + * The session type that supports local harness customization. + * Hardcoded for now — ideally providers would declare harness support explicitly. + */ +const LOCAL_HARNESS_SESSION_TYPE = 'local'; /** * Sessions-window override of the customization harness service. * - * The Local harness is registered when the `sessions.chat.localAgent.enabled` - * setting is true (the default). When the setting is toggled, the harness is - * dynamically added or removed so that the Customizations editor reflects the + * The Local harness is registered when a provider offers a session type + * matching {@link LOCAL_HARNESS_SESSION_TYPE}. When providers are added or + * removed (or their session types change), the harness is dynamically + * added or removed so that the Customizations editor reflects the * current state. * * The Copilot CLI extension provides its harness (with `itemProvider`) via @@ -30,7 +35,7 @@ export class SessionsCustomizationHarnessService extends CustomizationHarnessSer constructor( @IPromptsService promptsService: IPromptsService, - @IConfigurationService configurationService: IConfigurationService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, ) { const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; const localHarness = createVSCodeHarnessDescriptor(localExtras); @@ -41,17 +46,18 @@ export class SessionsCustomizationHarnessService extends CustomizationHarnessSer promptsService, ); - // Register the local harness dynamically so it can be toggled - // when the `sessions.chat.localAgent.enabled` setting changes. - if (configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING) !== false) { - this._localHarnessRegistration = this.registerExternalHarness(localHarness); - } + const sync = () => this._syncLocalHarness(localHarness, this._hasLocalSessionType()); - configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(LOCAL_SESSION_ENABLED_SETTING)) { - this._syncLocalHarness(localHarness, configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING) !== false); - } - }); + this.sessionsManagementService.onDidChangeSessionTypes(sync); + + // Initial sync + sync(); + } + + private _hasLocalSessionType(): boolean { + return this.sessionsManagementService.getAllSessionTypes().some( + t => t.id === LOCAL_HARNESS_SESSION_TYPE + ); } private _syncLocalHarness(descriptor: IHarnessDescriptor, enabled: boolean): void { From 40f8fa49a29a6ead521f88916dddbee16466751b Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 13 May 2026 15:10:27 +0200 Subject: [PATCH 09/27] fix window controls in agents window --- src/vs/sessions/browser/parts/titlebarPart.ts | 6 +- .../electron-browser/parts/titlebarPart.ts | 90 ++++++++++++++++++- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 3a4735f9961bf..d0f492902f056 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -75,8 +75,8 @@ export class TitlebarPart extends Part implements ITitlebarPart { //#endregion - private rootContainer!: HTMLElement; - private windowControlsContainer: HTMLElement | undefined; + protected rootContainer!: HTMLElement; + protected windowControlsContainer: HTMLElement | undefined; private leftContent!: HTMLElement; private leftToolbarContainer!: HTMLElement; @@ -271,7 +271,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { } } - private onContextMenu(e: MouseEvent): void { + protected onContextMenu(e: MouseEvent): void { const event = new StandardMouseEvent(getWindow(this.element), e); this.contextMenuService.showContextMenu({ getAnchor: () => event, diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts index 638f04ce2c2fa..ae368d1bf22c1 100644 --- a/src/vs/sessions/electron-browser/parts/titlebarPart.ts +++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts @@ -4,7 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { getZoomFactor } from '../../../base/browser/browser.js'; -import { getWindow, getWindowId } from '../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventType, getWindow, getWindowId, hide, show } from '../../../base/browser/dom.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { Event } from '../../../base/common/event.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; @@ -13,7 +16,7 @@ import { INativeHostService } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { useWindowControlsOverlay } from '../../../platform/window/common/window.js'; +import { hasNativeTitlebar, useWindowControlsOverlay } from '../../../platform/window/common/window.js'; import { IsWindowAlwaysOnTopContext } from '../../../workbench/common/contextkeys.js'; import { IHostService } from '../../../workbench/services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; @@ -21,11 +24,14 @@ import { IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titleba import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; import { TitlebarPart, TitleService } from '../../browser/parts/titlebarPart.js'; -import { isMacintosh } from '../../../base/common/platform.js'; +import { isMacintosh, isWindows } from '../../../base/common/platform.js'; import { localize } from '../../../nls.js'; export class NativeTitlebarPart extends TitlebarPart { + private maxRestoreControl: HTMLElement | undefined; + private resizer: HTMLElement | undefined; + private cachedWindowControlStyles: { bgColor: string; fgColor: string } | undefined; private cachedWindowControlHeight: number | undefined; @@ -64,7 +70,83 @@ export class NativeTitlebarPart extends TitlebarPart { } window.document.title = agentsTitle; - return super.createContentArea(parent); + const result = super.createContentArea(parent); + const targetWindow = getWindow(parent); + const targetWindowId = getWindowId(targetWindow); + + // Custom Window Controls (Native Windows/Linux) when window.controlsStyle is "custom" + if ( + !hasNativeTitlebar(this.configurationService) && // not for native title bars + !useWindowControlsOverlay(this.configurationService) && // not when controls are natively drawn + this.windowControlsContainer + ) { + + // Minimize + const minimizeIcon = append(this.windowControlsContainer, $('div.window-icon.window-minimize' + ThemeIcon.asCSSSelector(Codicon.chromeMinimize))); + this._register(addDisposableListener(minimizeIcon, EventType.CLICK, () => { + this.nativeHostService.minimizeWindow({ targetWindowId }); + })); + + // Restore + this.maxRestoreControl = append(this.windowControlsContainer, $('div.window-icon.window-max-restore')); + this._register(addDisposableListener(this.maxRestoreControl, EventType.CLICK, async () => { + const maximized = await this.nativeHostService.isMaximized({ targetWindowId }); + if (maximized) { + return this.nativeHostService.unmaximizeWindow({ targetWindowId }); + } + + return this.nativeHostService.maximizeWindow({ targetWindowId }); + })); + + // Close + const closeIcon = append(this.windowControlsContainer, $('div.window-icon.window-close' + ThemeIcon.asCSSSelector(Codicon.chromeClose))); + this._register(addDisposableListener(closeIcon, EventType.CLICK, () => { + this.nativeHostService.closeWindow({ targetWindowId }); + })); + + // Resizer + this.resizer = append(this.rootContainer, $('div.resizer')); + this._register(Event.runAndSubscribe(this.layoutService.onDidChangeWindowMaximized, ({ windowId, maximized }) => { + if (windowId === targetWindowId) { + this.onDidChangeWindowMaximized(maximized); + } + }, { windowId: targetWindowId, maximized: this.layoutService.isWindowMaximized(targetWindow) })); + } + + // Window System Context Menu + // See https://github.com/electron/electron/issues/24893 + if (isWindows && !hasNativeTitlebar(this.configurationService)) { + this._register(this.nativeHostService.onDidTriggerWindowSystemContextMenu(({ windowId, x, y }) => { + if (targetWindowId !== windowId) { + return; + } + + const zoomFactor = getZoomFactor(getWindow(this.element)); + this.onContextMenu(new MouseEvent(EventType.MOUSE_UP, { clientX: x / zoomFactor, clientY: y / zoomFactor })); + })); + } + + return result; + } + + private onDidChangeWindowMaximized(maximized: boolean): void { + if (this.maxRestoreControl) { + if (maximized) { + this.maxRestoreControl.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chromeMaximize)); + this.maxRestoreControl.classList.add(...ThemeIcon.asClassNameArray(Codicon.chromeRestore)); + } else { + this.maxRestoreControl.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chromeRestore)); + this.maxRestoreControl.classList.add(...ThemeIcon.asClassNameArray(Codicon.chromeMaximize)); + } + } + + if (this.resizer) { + if (maximized) { + hide(this.resizer); + } else { + show(this.resizer); + } + } } private async handleWindowsAlwaysOnTop(targetWindowId: number, contextKeyService: IContextKeyService): Promise { From 104aa078054eb6541849af197a737d310b009a8e Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 13 May 2026 16:04:47 +0200 Subject: [PATCH 10/27] fix window title in agentTtitleBarStatusWidget --- src/vs/sessions/browser/parts/titlebarPart.ts | 13 +++++++++++++ .../browser/parts/titlebar/titlebarPart.ts | 6 +++++- .../experiments/agentTitleBarStatusWidget.ts | 13 +++++-------- .../services/title/browser/titleService.ts | 8 ++++++++ 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 3a4735f9961bf..da1a47f999d5d 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -29,6 +29,7 @@ import { IEditorGroupsContainer } from '../../../workbench/services/editor/commo import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; import { safeIntl } from '../../../base/common/date.js'; import { ITitlebarPart, ITitleProperties, ITitleVariable, IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titlebar/titlebarPart.js'; +import { WindowTitle } from '../../../workbench/browser/parts/titlebar/windowTitle.js'; import { Menus } from '../menus.js'; import { IsNewChatSessionContext } from '../../common/contextkeys.js'; @@ -473,5 +474,17 @@ export class TitleService extends MultiWindowParts implements ITit } } + private _windowTitle: WindowTitle | undefined; + + get windowTitle(): WindowTitle { + // The Agents window title bar does not render `window.title`, so we + // lazily construct a `WindowTitle` only when a consumer (e.g. a custom + // command center widget) actually asks for one. + if (!this._windowTitle) { + this._windowTitle = this._register(this.instantiationService.createInstance(WindowTitle, mainWindow)); + } + return this._windowTitle; + } + //#endregion } diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index d03b23148a500..6a984815807c0 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -216,6 +216,10 @@ export class BrowserTitleService extends MultiWindowParts i } } + get windowTitle(): WindowTitle { + return this.mainPart.windowTitle; + } + //#endregion } @@ -292,7 +296,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private readonly isCompactContextKey: IContextKey; - private readonly windowTitle: WindowTitle; + readonly windowTitle: WindowTitle; protected readonly instantiationService: IInstantiationService; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index f4cadd4aa571a..3c5bb258b92d3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -43,6 +43,7 @@ import { ChatConfiguration } from '../../../common/constants.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetService } from '../../chat.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { ITitleService } from '../../../../../services/title/browser/titleService.js'; // Telemetry types type AgentStatusClickAction = @@ -146,11 +147,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Menu for ChatTitleBarMenu items (same as chat controls dropdown) */ private readonly _chatTitleBarMenu; - /** WindowTitle instance for honoring the user's window.title setting */ - private readonly _windowTitle: WindowTitle; - constructor( action: IAction, + private readonly _windowTitle: WindowTitle, options: IBaseActionViewItemOptions | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @IAgentTitleBarStatusService private readonly agentTitleBarStatusService: IAgentTitleBarStatusService, @@ -177,9 +176,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Create menu for ChatTitleBarMenu to show in sparkle section dropdown this._chatTitleBarMenu = this._register(this.menuService.createMenu(MenuId.ChatTitleBarMenu, this.contextKeyService)); - // Create WindowTitle to honor the user's window.title setting - this._windowTitle = this._register(this.instantiationService.createInstance(WindowTitle, mainWindow)); - // Re-render when control mode or session info changes this._register(this.agentTitleBarStatusService.onDidChangeMode(() => { this._render(); @@ -1400,7 +1396,8 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, - @IContextKeyService contextKeyService: IContextKeyService + @IContextKeyService contextKeyService: IContextKeyService, + @ITitleService titleService: ITitleService, ) { super(); @@ -1408,7 +1405,7 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben if (!(action instanceof SubmenuItemAction)) { return undefined; } - return instantiationService.createInstance(AgentTitleBarStatusWidget, action, options); + return instantiationService.createInstance(AgentTitleBarStatusWidget, action, titleService.windowTitle, options); }, undefined)); // Add/remove CSS classes on workbench based on settings. diff --git a/src/vs/workbench/services/title/browser/titleService.ts b/src/vs/workbench/services/title/browser/titleService.ts index 533160479846e..90dbe8c340e89 100644 --- a/src/vs/workbench/services/title/browser/titleService.ts +++ b/src/vs/workbench/services/title/browser/titleService.ts @@ -5,6 +5,7 @@ import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IAuxiliaryTitlebarPart, ITitlebarPart } from '../../../browser/parts/titlebar/titlebarPart.js'; +import { WindowTitle } from '../../../browser/parts/titlebar/windowTitle.js'; import { IEditorGroupsContainer } from '../../editor/common/editorGroupsService.js'; export const ITitleService = createDecorator('titleService'); @@ -13,6 +14,13 @@ export interface ITitleService extends ITitlebarPart { readonly _serviceBrand: undefined; + /** + * The shared {@link WindowTitle} instance for the main window. Used by + * components that need to render or react to the resolved `window.title` + * (template variables, decorations, etc.) without instantiating their own. + */ + readonly windowTitle: WindowTitle; + /** * Get the status bar part that is rooted in the provided container. */ From fcbd8875899067f9b6a3e7524bd7f43507673abc Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 13 May 2026 10:05:54 -0400 Subject: [PATCH 11/27] Remove old telemetry pipeline (#316236) --- extensions/copilot/package.json | 1 - .../src/extension/extension/vscode-node/services.ts | 10 ++++------ .../telemetry/common/msftTelemetrySender.ts | 13 +++---------- .../platform/telemetry/test/node/telemetry.spec.ts | 2 +- .../vscode-node/microsoftTelemetrySender.ts | 7 ++----- .../telemetry/vscode-node/telemetryServiceImpl.ts | 2 -- 6 files changed, 10 insertions(+), 25 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index a12de99ce35eb..cb607b4be012c 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4,7 +4,6 @@ "description": "AI chat features powered by Copilot", "version": "0.49.0", "build": "1", - "internalAIKey": "1058ec22-3c95-4951-8443-f26c1f325911", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", "ariaKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts index 38c4a21925b0f..ed3801b5d132e 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/services.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts @@ -182,11 +182,10 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IImageService, new SyncDescriptor(VSCodeImageServiceImpl)); builder.define(ITelemetryUserConfig, new SyncDescriptor(TelemetryUserConfigImpl, [undefined, undefined])); - const internalAIKey = extensionContext.extension.packageJSON.internalAIKey ?? ''; - const internalLargeEventAIKey = extensionContext.extension.packageJSON.internalLargeStorageAriaKey ?? ''; + const internalAIKey = extensionContext.extension.packageJSON.internalLargeStorageAriaKey ?? ''; const ariaKey = extensionContext.extension.packageJSON.ariaKey ?? ''; if (isTestMode || isScenarioAutomation) { - setupTelemetry(builder, extensionContext, internalAIKey, internalLargeEventAIKey, ariaKey); + setupTelemetry(builder, extensionContext, internalAIKey, ariaKey); // If we're in testing mode, then most code will be called from an actual test, // and not from here. However, some objects will capture the `accessor` we pass // here and then re-use it later. This is particularly the case for those objects @@ -194,7 +193,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio // method parameters. builder.define(ICopilotTokenManager, getOrCreateTestingCopilotTokenManager(env.devDeviceId)); } else { - setupTelemetry(builder, extensionContext, internalAIKey, internalLargeEventAIKey, ariaKey); + setupTelemetry(builder, extensionContext, internalAIKey, ariaKey); builder.define(ICopilotTokenManager, new SyncDescriptor(VSCodeCopilotTokenManager)); } @@ -325,13 +324,12 @@ function setupMSFTExperimentationService(builder: IInstantiationServiceBuilder, } } -function setupTelemetry(builder: IInstantiationServiceBuilder, extensionContext: ExtensionContext, internalAIKey: string, internalLargeEventAIKey: string, externalAIKey: string) { +function setupTelemetry(builder: IInstantiationServiceBuilder, extensionContext: ExtensionContext, internalAIKey: string, externalAIKey: string) { if (ExtensionMode.Production === extensionContext.extensionMode && !isScenarioAutomation) { builder.define(ITelemetryService, new SyncDescriptor(TelemetryService, [ extensionContext.extension.packageJSON.name, internalAIKey, - internalLargeEventAIKey, externalAIKey, APP_INSIGHTS_KEY_STANDARD, APP_INSIGHTS_KEY_ENHANCED, diff --git a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts index 0bbc00c2509dc..a50df50a05ce7 100644 --- a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts +++ b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts @@ -17,7 +17,6 @@ export interface ITelemetryReporter extends ITelemetrySender { export class BaseMsftTelemetrySender implements IMSFTTelemetrySender { // Telemetry reporter used for collecting telemetry on internal Microsoft customers protected _internalTelemetryReporter: ITelemetryReporter | undefined; - protected _internalLargeEventTelemetryReporter: ITelemetryReporter | undefined; private _externalTelemetryReporter: ITelemetryReporter; protected readonly _disposables: DisposableStore = new DisposableStore(); @@ -29,9 +28,9 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender { constructor( copilotTokenStore: ICopilotTokenStore, - private readonly _createTelemetryReporter: (internal: boolean, largeEvents: boolean) => ITelemetryReporter + private readonly _createTelemetryReporter: (internal: boolean) => ITelemetryReporter ) { - this._externalTelemetryReporter = this._createTelemetryReporter(false, false); + this._externalTelemetryReporter = this._createTelemetryReporter(false); this.processToken(copilotTokenStore.copilotToken); this._disposables.add(copilotTokenStore.onDidStoreUpdate(() => this.processToken(copilotTokenStore.copilotToken))); } @@ -50,9 +49,6 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender { properties = { ...properties, 'common.tid': this._tid, 'common.userName': this._username ?? 'undefined' }; measurements = { ...measurements, 'common.isVscodeTeamMember': this._vscodeTeamMember ? 1 : 0 }; this._internalTelemetryReporter.sendRawTelemetryEvent(eventName, properties, measurements); - if (this._internalLargeEventTelemetryReporter) { // Also duplicate events to the large data store for testing of the pipeline - this._internalLargeEventTelemetryReporter.sendRawTelemetryEvent(eventName, properties, measurements); - } } /** @@ -106,15 +102,12 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender { this._isInternal = !!token?.isInternal; if (this._isInternal) { - this._internalTelemetryReporter ??= this._createTelemetryReporter(true, false); - this._internalLargeEventTelemetryReporter ??= this._createTelemetryReporter(true, true); + this._internalTelemetryReporter ??= this._createTelemetryReporter(true); } if (!token || !this._isInternal) { this._internalTelemetryReporter?.dispose(); this._internalTelemetryReporter = undefined; - this._internalLargeEventTelemetryReporter?.dispose(); - this._internalLargeEventTelemetryReporter = undefined; return; } } diff --git a/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts b/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts index 07e443c86644b..21ee3eee166fc 100644 --- a/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts +++ b/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts @@ -97,7 +97,7 @@ suite('Microsoft Telemetry Sender', function () { test('should send internal telemetry event', () => { sender.sendInternalTelemetryEvent('testInternalEvent', { foo: 'bar' }, { 'testMeasure': 1 }); - expect(mockInternalReporter.sendRawTelemetryEvent).toHaveBeenCalledTimes(2); + expect(mockInternalReporter.sendRawTelemetryEvent).toHaveBeenCalledOnce(); expect(mockInternalReporter.sendRawTelemetryEvent).toHaveBeenCalledWith( 'testInternalEvent', { foo: 'bar', 'common.tid': 'testTid', 'common.userName': 'testUser' }, diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts index e6d45bd0a969e..1271f990629dd 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/microsoftTelemetrySender.ts @@ -10,16 +10,13 @@ import { BaseMsftTelemetrySender } from '../common/msftTelemetrySender'; export class MicrosoftTelemetrySender extends BaseMsftTelemetrySender { constructor( internalAIKey: string, - internalLargeEventAIKey: string, externalAIKey: string, tokenStore: ICopilotTokenStore, customFetcher: CustomFetcher ) { - const telemetryReporterFactory = (internal: boolean, largeEventReporter: boolean) => { - if (internal && !largeEventReporter) { + const telemetryReporterFactory = (internal: boolean) => { + if (internal) { return new TelemetryReporter(internalAIKey, undefined, undefined, customFetcher); - } else if (internal && largeEventReporter) { - return new TelemetryReporter(internalLargeEventAIKey, undefined, undefined, customFetcher); } else { return new TelemetryReporter(externalAIKey, undefined, undefined, customFetcher); } diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts index d5fd0384b9bc9..b03c5e1a747d7 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts @@ -23,7 +23,6 @@ export class TelemetryService extends BaseTelemetryService { constructor( extensionName: string, internalMSFTAIKey: string, - internalLargeEventMSFTAIKey: string, externalMSFTAIKey: string, externalGHAIKey: string, estrictedGHAIKey: string, @@ -46,7 +45,6 @@ export class TelemetryService extends BaseTelemetryService { }; const microsoftTelemetrySender = new MicrosoftTelemetrySender( internalMSFTAIKey, - internalLargeEventMSFTAIKey, externalMSFTAIKey, tokenStore, customFetcher From 1f66fd7bd5c238570d92e86ec4a5754f936abb74 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 13 May 2026 16:07:38 +0200 Subject: [PATCH 12/27] sessions: improve AI readiness of skill and instruction files (#316239) sessions: improve AI readiness of skill and instructions - Restructure SKILL.md: remove duplicated content, add mandatory pre-change reads (coding guidelines, source-code-organization), add 'When to read' column to spec table, make valid-layers-check mandatory, add common pitfalls section - Enhance sessions.instructions.md: add architecture overview, internal layer diagram, core services table, key development patterns (menus, context keys, observables), and learnings section - Remove window isolation references (not applicable) - Remove Development Recipes from skill (already in SESSIONS.md) - Add explicit 'Before Making Any Changes' mandatory reads block to ensure coverage in all harnesses (not just VS Code Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/instructions/sessions.instructions.md | 76 +++++++ .github/skills/sessions/SKILL.md | 77 +++----- src/vs/sessions/SESSIONS.md | 4 + src/vs/sessions/SESSIONS_LIST.md | 186 ++++++++++++++++++ 4 files changed, 290 insertions(+), 53 deletions(-) create mode 100644 src/vs/sessions/SESSIONS_LIST.md diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md index 88189a950f964..cb56021ab8360 100644 --- a/.github/instructions/sessions.instructions.md +++ b/.github/instructions/sessions.instructions.md @@ -11,6 +11,76 @@ When working on files under `src/vs/sessions/`, use these skills for detailed gu - **`sessions`** skill — covers the full architecture: layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines +## Architecture at a Glance + +``` +vs/sessions (Agents Window) ← this layer + ↓ imports from +vs/workbench ← standard VS Code + ↓ +vs/editor → vs/platform → vs/base +``` + +**Layer rule:** `vs/sessions` imports from `vs/workbench` and below. `vs/workbench` must **never** import from `vs/sessions`. + +**Internal layers** (see `src/vs/sessions/LAYERS.md`): +``` +Entry Points → contrib/* / contrib/providers/* / services/* → browser/ & common/ (core) +``` + +**Key constraint:** `contrib/*` must NOT import from `contrib/providers/*`. Providers are the most permissive contrib layer and may import from non-provider contribs, services, core, and sibling providers. + +## Core Services + +| Service | Interface file | Purpose | +|---------|---------------|---------| +| `ISessionsManagementService` | `services/sessions/common/sessionsManagement.ts` | Active session tracking, navigation, CRUD operations | +| `ISessionsProvidersService` | `services/sessions/common/sessionsProvider.ts` | Provider registry (register/unregister/lookup) | +| `ISession` / `IChat` | `services/sessions/common/session.ts` | Session and chat data interfaces with observable properties | + +## Key Development Patterns + +### Registering Contributions + +All features register through the contribution model and must be imported in entry points: +- `sessions.common.main.ts` — cross-platform contributions +- `sessions.desktop.main.ts` — desktop/Electron-specific +- `sessions.web.main.ts` — web-specific + +### Menu Registration + +Always use `Menus.*` from `browser/menus.ts` — never `MenuId.*` from `vs/platform/actions`: +- `Menus.TitleBarLeftLayout` / `Menus.TitleBarRightLayout` — titlebar actions +- `Menus.SidebarTitle` — sidebar header actions +- `Menus.AuxiliaryBarTitle` — auxiliary bar header actions +- `Menus.ChatBarTitle` — chat bar header actions + +### Context Keys + +All sessions-specific context keys live in `common/contextkeys.ts`: +- `IsNewChatSessionContext` — whether showing the new session view +- `ActiveSessionProviderIdContext` — which provider owns the active session +- `ActiveSessionTypeContext` — session type of the active session +- `IsPhoneLayoutContext` — whether in phone layout mode +- `ChatBarVisibleContext` / `ChatBarFocusContext` — chat bar state + +### Observable Patterns + +```typescript +// Subscribe to session state changes +this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + const title = session?.title.read(reader); + // React to changes +})); + +// Batch updates +transaction(tx => { + this._title.set(newTitle, tx); + this._status.set(newStatus, tx); +}); +``` + ## Mobile Component Architecture The Agents window has an established mobile architecture (documented in `src/vs/sessions/MOBILE.md`). When adding phone-specific UI — bottom sheets, action sheets, mobile pickers, or any interaction that differs from desktop — follow these rules: @@ -34,3 +104,9 @@ The Agents window can run on touch-capable platforms (notably iOS). Follow these - Do not use `EventType.MOUSE_DOWN`, `EventType.MOUSE_UP`, or `EventType.MOUSE_MOVE` with `addDisposableListener` directly — on iOS, these events don't fire because the platform uses pointer events. Use `addDisposableGenericMouseDownListener`, `addDisposableGenericMouseUpListener`, or `addDisposableGenericMouseMoveListener` instead, which automatically select the correct event type per platform. - For custom clickable elements (e.g. picker triggers, title bar pills, or other `
`/`` elements styled as buttons) that open pickers or menus on click, listen to **both** `EventType.CLICK` and `TouchEventType.Tap` and call `Gesture.addTarget` on the element. On touch devices, including iOS, VS Code relies on the gesture system to emit `TouchEventType.Tap`, and `EventType.CLICK` alone may not reliably fire there. The base `Button` class already does this correctly, so this rule applies to custom non-`