From f45a876b77c6052ae78b505a389cc737c9cc5ed2 Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 15:34:03 -0700 Subject: [PATCH 01/19] [Perf] agentHost: avoid collecting Copilot process logs in debug exports (#331709) * agentHost: avoid collecting Copilot process logs Disable SDK process-log collection during debug exports while preserving session event and shell logs. Add focused coverage for both session-scoped and host-wide requests.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: address debug log review feedback Document why SDK process logs are excluded and make the debug-log mock return destination-specific paths. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: remove redundant process log comment (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotAgentSession.ts | 2 +- .../test/node/copilotAgentSession.test.ts | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 4f86023d1392f4..5790382c1e3662 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -750,7 +750,7 @@ export class CopilotAgentSession extends Disposable { destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, include: { events: includeSessionLogs, - processLogs: true, + processLogs: false, shellLogs: includeSessionLogs, }, }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 72efdeba256a6c..12e0fe9f803a80 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -85,6 +85,7 @@ class MockCopilotSession { readonly gitHubCredentialUpdates: Array<{ credentials?: { type: 'token'; host: string; token: string } }> = []; gitHubCredentialUpdateResult = { success: true, copilotUserResolved: true }; gitHubCredentialUpdateError: Error | undefined; + readonly collectLogsCalls: Parameters[0][] = []; readonly experimentalModeUpdates: boolean[] = []; experimentalModeUpdateSuccess = true; sandboxConfigUpdateSuccess = true; @@ -252,6 +253,15 @@ class MockCopilotSession { } readonly rpc = { + debug: { + collectLogs: async (params: Parameters[0]) => { + this.collectLogsCalls.push(params); + const { destination } = params; + return destination.kind === 'directory' + ? { kind: 'directory' as const, path: destination.outputDirectory, entries: [] } + : { kind: 'archive' as const, path: destination.outputPath, entries: [] }; + }, + }, mode: { get: async () => ({ mode: 'interactive' as const }), set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { @@ -1012,6 +1022,22 @@ suite('CopilotAgentSession', () => { }); }); + test('collects SDK debug logs without process logs', async () => { + const { session, mockSession } = await createAgentSession(disposables); + const outputDirectory = URI.file('/tmp/agent-host-debug'); + + await session.collectDebugLogs(outputDirectory, true); + await session.collectDebugLogs(outputDirectory, false); + + assert.deepStrictEqual(mockSession.collectLogsCalls, [{ + destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, + include: { events: true, processLogs: false, shellLogs: true }, + }, { + destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, + include: { events: false, processLogs: false, shellLogs: false }, + }]); + }); + suite('CopilotSessionWrapper', () => { test('fires unhandled events when no wrapped listener is registered', () => { const mockSession = new MockCopilotSession(); From bee4994ef84e7930a697e75704600b5bef038c02 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 19 Aug 2026 16:07:19 -0700 Subject: [PATCH 02/19] base: share WebSocket framing across tunnel transports (#331706) * base: share WebSocket framing across tunnel transports Extracts the RFC 6455 frame parser and encoder into the shared IPC layer so node IPC sockets and browser tunnel connections use the same implementation. - Supports masked client frames, fragmented messages, extended payload lengths, control frames, close handshakes, and protocol validation. - Removes the websocket framing package and its browser compatibility shims from the Dev Tunnels web bundle. - Adds focused coverage for the shared codec and browser tunnel transport. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * base: harden shared WebSocket framing Addresses review feedback for lifecycle, resource limits, and high-throughput IPC performance in the shared RFC 6455 implementation. - Restores browser client frame, message, and close-handshake limits. - Adds zero-copy in-place unmasking for the Node IPC path. - Handles Pong frames explicitly and adds focused regression coverage. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/next/devTunnelsShims/bufferutil.cjs | 16 - build/next/devTunnelsShims/utf8Validate.cjs | 15 - build/next/devTunnelsWeb.ts | 18 +- build/next/devTunnelsWebEntry.js | 4 - .../base/parts/ipc/common/webSocketFraming.ts | 271 +++++++++++++ src/vs/base/parts/ipc/node/ipc.net.ts | 197 ++-------- .../ipc/test/common/webSocketFraming.test.ts | 145 +++++++ .../agentHost/common/tunnelMessageSocket.ts | 50 +-- .../agentHost/common/webSocketOverDuplex.ts | 364 +++++++++++------ .../common/tunnelAgentHostConnector.test.ts | 17 +- .../test/common/webSocketOverDuplex.test.ts | 370 +++++++++++++++--- .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 1 + .../browser/browserTunnelAgentHostService.ts | 11 +- .../browser/devTunnelsWebLoader.ts | 3 +- .../browserTunnelAgentHostService.test.ts | 27 +- 15 files changed, 1005 insertions(+), 504 deletions(-) delete mode 100644 build/next/devTunnelsShims/bufferutil.cjs delete mode 100644 build/next/devTunnelsShims/utf8Validate.cjs create mode 100644 src/vs/base/parts/ipc/common/webSocketFraming.ts create mode 100644 src/vs/base/parts/ipc/test/common/webSocketFraming.test.ts diff --git a/build/next/devTunnelsShims/bufferutil.cjs b/build/next/devTunnelsShims/bufferutil.cjs deleted file mode 100644 index 92adc2c81a6ce2..00000000000000 --- a/build/next/devTunnelsShims/bufferutil.cjs +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -exports.mask = function mask(source, mask, output, offset, length) { - for (let i = 0; i < length; i++) { - output[offset + i] = source[i] ^ mask[i & 3]; - } -}; - -exports.unmask = function unmask(buffer, mask) { - for (let i = 0; i < buffer.length; i++) { - buffer[i] ^= mask[i & 3]; - } -}; diff --git a/build/next/devTunnelsShims/utf8Validate.cjs b/build/next/devTunnelsShims/utf8Validate.cjs deleted file mode 100644 index 72acbbbaca3129..00000000000000 --- a/build/next/devTunnelsShims/utf8Validate.cjs +++ /dev/null @@ -1,15 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); - -module.exports = function isValidUTF8(buffer) { - try { - utf8Decoder.decode(buffer); - return true; - } catch { - return false; - } -}; diff --git a/build/next/devTunnelsWeb.ts b/build/next/devTunnelsWeb.ts index d1f258a6df577c..4a0ec0abfabfc4 100644 --- a/build/next/devTunnelsWeb.ts +++ b/build/next/devTunnelsWeb.ts @@ -24,7 +24,6 @@ const allowedImporterRoots = [ path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-connections'), path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-management'), path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-contracts'), - path.join(NODE_MODULES_ROOT, 'websocket'), ]; const nodeBuiltinNames = ['net', 'os', 'path', 'crypto', 'child_process', 'fs', 'http', 'https', 'tls', 'dns', 'zlib']; const nodeBuiltinFilter = new RegExp(`^(?:node:)?(?:${nodeBuiltinNames.join('|')}|stream|buffer)$`); @@ -100,25 +99,18 @@ export function devTunnelsBrowserShimPlugin(): esbuild.Plugin { return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; }); - build.onResolve({ filter: /^\.[\\/]node[\\/]/ }, args => { - if (!isSshNodeAlgorithmImport(args)) { - return; - } - return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; - }); - - build.onResolve({ filter: /^bufferutil$/ }, args => { + build.onResolve({ filter: /^websocket$/ }, args => { if (!isAllowedImporter(args.importer)) { return; } - return { path: path.join(SHIMS_ROOT, 'bufferutil.cjs') }; + return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; }); - build.onResolve({ filter: /^utf-8-validate$/ }, args => { - if (!isAllowedImporter(args.importer)) { + build.onResolve({ filter: /^\.[\\/]node[\\/]/ }, args => { + if (!isSshNodeAlgorithmImport(args)) { return; } - return { path: path.join(SHIMS_ROOT, 'utf8Validate.cjs') }; + return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; }); build.onResolve({ filter: /^vscode-jsonrpc$/ }, args => { diff --git a/build/next/devTunnelsWebEntry.js b/build/next/devTunnelsWebEntry.js index 604f2b82f969f2..7c9afff65cb360 100644 --- a/build/next/devTunnelsWebEntry.js +++ b/build/next/devTunnelsWebEntry.js @@ -6,14 +6,10 @@ import { TunnelManagementHttpClient, ManagementApiVersions } from '@microsoft/dev-tunnels-management'; import { TunnelRelayTunnelClient } from '@microsoft/dev-tunnels-connections'; import { TunnelAccessScopes } from '@microsoft/dev-tunnels-contracts'; -// The package root resolves to lib/browser.js, a native-WebSocket wrapper. This deep import provides -// RFC 6455 framing for an existing duplex stream and must not be replaced with the package root. -import WebSocketConnection from 'websocket/lib/WebSocketConnection'; export { TunnelManagementHttpClient, ManagementApiVersions, TunnelRelayTunnelClient, TunnelAccessScopes, - WebSocketConnection, }; diff --git a/src/vs/base/parts/ipc/common/webSocketFraming.ts b/src/vs/base/parts/ipc/common/webSocketFraming.ts new file mode 100644 index 00000000000000..f23733f20a3183 --- /dev/null +++ b/src/vs/base/parts/ipc/common/webSocketFraming.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../common/buffer.js'; +import { ChunkStream } from './ipc.net.js'; + +const firstByteFinalMask = 0b10000000; +const firstByteCompressedMask = 0b01000000; +const firstByteReservedMask = 0b00110000; +const secondByteMaskedMask = 0b10000000; +const payloadLengthMask = 0b01111111; +const extendedPayloadLength16 = 126; +const extendedPayloadLength64 = 127; +const maximum32BitPayloadLength = 0xffff_ffff; + +/** RFC 6455 frame opcode values supported by the framing codec. */ +export const enum WebSocketOpcode { + Continuation = 0x0, + Text = 0x1, + Binary = 0x2, + Close = 0x8, + Ping = 0x9, + Pong = 0xA, +} + +/** A parsed RFC 6455 frame with an unmasked payload. */ +export interface IWebSocketFrame { + readonly final: boolean; + readonly compressed: boolean; + readonly opcode: WebSocketOpcode; + readonly payload: VSBuffer; + /** The mask from the wire, including a zero-valued mask, when present. */ + readonly mask?: number; +} + +/** Options for encoding an RFC 6455 frame. */ +export interface IWebSocketFrameOptions { + readonly final?: boolean; + readonly compressed?: boolean; + readonly opcode: WebSocketOpcode; + /** Four mask bytes. When supplied, sets MASK and applies it to a payload copy. */ + readonly mask?: number; +} + +/** Options for parsing RFC 6455 frames. */ +export interface IWebSocketFrameParserOptions { + /** Maximum accepted frame payload length. */ + readonly maxPayloadLength?: number; + /** Unmask owned payload buffers in place instead of allocating a copy. */ + readonly unmaskInPlace?: boolean; +} + +/** Indicates that a WebSocket frame exceeded the configured payload limit. */ +export class WebSocketFrameTooLargeError extends Error { + constructor(readonly payloadLength: number, readonly maxPayloadLength: number) { + super(`WebSocket frame payload length ${payloadLength} exceeds the configured limit of ${maxPayloadLength}.`); + } +} + +/** Incrementally parses RFC 6455 frames from arbitrarily chunked input. */ +export class WebSocketFrameParser { + + private readonly _incomingData = new ChunkStream(); + private readonly _maxPayloadLength: number; + private readonly _unmaskInPlace: boolean; + + constructor(options: IWebSocketFrameParserOptions = {}) { + this._maxPayloadLength = options.maxPayloadLength ?? maximum32BitPayloadLength; + this._unmaskInPlace = options.unmaskInPlace ?? false; + if (!Number.isInteger(this._maxPayloadLength) || this._maxPayloadLength < 0 || this._maxPayloadLength > maximum32BitPayloadLength) { + throw new Error('WebSocket frame payload limits must be unsigned 32-bit integers.'); + } + } + + /** + * Accepts a network chunk and returns every complete frame it contains. + */ + acceptChunk(data: VSBuffer): readonly IWebSocketFrame[] { + if (data.byteLength === 0) { + return []; + } + + this._incomingData.acceptChunk(data); + const frames: IWebSocketFrame[] = []; + while (this._incomingData.byteLength >= 2) { + const initialHeader = this._incomingData.peek(2); + const firstByte = initialHeader.readUInt8(0); + const secondByte = initialHeader.readUInt8(1); + const payloadLengthMarker = secondByte & payloadLengthMask; + const extendedPayloadLengthSize = payloadLengthMarker === extendedPayloadLength16 ? 2 : payloadLengthMarker === extendedPayloadLength64 ? 8 : 0; + const masked = (secondByte & secondByteMaskedMask) !== 0; + const headerLength = 2 + extendedPayloadLengthSize + (masked ? 4 : 0); + if (this._incomingData.byteLength < headerLength) { + break; + } + + const header = this._incomingData.peek(headerLength); + const payloadLength = getPayloadLength(header, payloadLengthMarker); + if (payloadLength > this._maxPayloadLength) { + throw new WebSocketFrameTooLargeError(payloadLength, this._maxPayloadLength); + } + validateFrame(firstByte, payloadLength); + const opcode = firstByte & 0b00001111; + validateOpcode(opcode); + if (this._incomingData.byteLength < headerLength + payloadLength) { + break; + } + + this._incomingData.read(headerLength); + const payload = this._incomingData.read(payloadLength); + const mask = masked ? header.readUInt32BE(headerLength - 4) : undefined; + let unmaskedPayload = payload; + if (mask !== undefined) { + if (this._unmaskInPlace) { + applyWebSocketMask(unmaskedPayload, mask); + } else { + unmaskedPayload = copyAndApplyWebSocketMask(payload, mask); + } + } + frames.push({ + final: (firstByte & firstByteFinalMask) !== 0, + compressed: (firstByte & firstByteCompressedMask) !== 0, + opcode, + payload: unmaskedPayload, + mask, + }); + } + return frames; + } +} + +/** + * Encodes one RFC 6455 frame without mutating the supplied payload. + */ +export function encodeWebSocketFrame(payload: VSBuffer, options: IWebSocketFrameOptions): VSBuffer { + const final = options.final ?? true; + const compressed = options.compressed ?? false; + validateOpcode(options.opcode); + validateMask(options.mask); + validateFrame((final ? firstByteFinalMask : 0) | (compressed ? firstByteCompressedMask : 0) | options.opcode, payload.byteLength); + + const headerLength = getHeaderLength(payload.byteLength, options.mask !== undefined); + const header = VSBuffer.alloc(headerLength); + header.writeUInt8((final ? firstByteFinalMask : 0) | (compressed ? firstByteCompressedMask : 0) | options.opcode, 0); + + let offset = 2; + if (payload.byteLength < extendedPayloadLength16) { + header.writeUInt8((options.mask === undefined ? 0 : secondByteMaskedMask) | payload.byteLength, 1); + } else if (payload.byteLength < 2 ** 16) { + header.writeUInt8((options.mask === undefined ? 0 : secondByteMaskedMask) | extendedPayloadLength16, 1); + header.writeUInt8(payload.byteLength >>> 8, offset++); + header.writeUInt8(payload.byteLength, offset++); + } else { + header.writeUInt8((options.mask === undefined ? 0 : secondByteMaskedMask) | extendedPayloadLength64, 1); + header.writeUInt32BE(0, offset); + offset += 4; + header.writeUInt32BE(payload.byteLength, offset); + offset += 4; + } + + if (options.mask === undefined) { + return VSBuffer.concat([header, payload]); + } + + header.writeUInt32BE(options.mask, offset); + return VSBuffer.concat([header, copyAndApplyWebSocketMask(payload, options.mask)]); +} + +/** Applies an RFC 6455 four-byte mask to a buffer in place. */ +export function applyWebSocketMask(payload: VSBuffer, mask: number): void { + validateMask(mask); + if (mask === 0) { + return; + } + + const wordCount = payload.byteLength >>> 2; + for (let index = 0; index < wordCount; index++) { + const offset = index * 4; + payload.writeUInt32BE(payload.readUInt32BE(offset) ^ mask, offset); + } + + const offset = wordCount * 4; + const remainingByteCount = payload.byteLength - offset; + if (remainingByteCount >= 1) { + payload.writeUInt8(payload.readUInt8(offset) ^ ((mask >>> 24) & 0xff), offset); + } + if (remainingByteCount >= 2) { + payload.writeUInt8(payload.readUInt8(offset + 1) ^ ((mask >>> 16) & 0xff), offset + 1); + } + if (remainingByteCount >= 3) { + payload.writeUInt8(payload.readUInt8(offset + 2) ^ ((mask >>> 8) & 0xff), offset + 2); + } +} + +function getHeaderLength(payloadLength: number, masked: boolean): number { + if (payloadLength > maximum32BitPayloadLength) { + throw new Error('WebSocket payload lengths greater than 2^32 - 1 are not supported.'); + } + if (payloadLength < extendedPayloadLength16) { + return 2 + (masked ? 4 : 0); + } + if (payloadLength < 2 ** 16) { + return 4 + (masked ? 4 : 0); + } + return 10 + (masked ? 4 : 0); +} + +function getPayloadLength(header: VSBuffer, marker: number): number { + if (marker < extendedPayloadLength16) { + return marker; + } + if (marker === extendedPayloadLength16) { + return header.readUInt8(2) * 2 ** 8 + header.readUInt8(3); + } + + const highBits = header.readUInt32BE(2); + if (highBits !== 0) { + throw new Error('WebSocket payload lengths greater than 2^32 - 1 are not supported.'); + } + return header.readUInt32BE(6); +} + +function validateFrame(firstByte: number, payloadLength: number): void { + if ((firstByte & firstByteReservedMask) !== 0) { + throw new Error('WebSocket frames must not set RSV2 or RSV3.'); + } + + const opcode = firstByte & 0b00001111; + validateOpcode(opcode); + if (isControlOpcode(opcode)) { + if ((firstByte & firstByteFinalMask) === 0) { + throw new Error('WebSocket control frames must be final.'); + } + if (payloadLength > 125) { + throw new Error('WebSocket control frames must not exceed 125 bytes.'); + } + if ((firstByte & firstByteCompressedMask) !== 0) { + throw new Error('WebSocket control frames must not set RSV1.'); + } + } +} + +function validateOpcode(opcode: number): asserts opcode is WebSocketOpcode { + if (opcode !== WebSocketOpcode.Continuation + && opcode !== WebSocketOpcode.Text + && opcode !== WebSocketOpcode.Binary + && opcode !== WebSocketOpcode.Close + && opcode !== WebSocketOpcode.Ping + && opcode !== WebSocketOpcode.Pong) { + throw new Error(`WebSocket frame has reserved opcode ${opcode}.`); + } +} + +function validateMask(mask: number | undefined): void { + if (mask !== undefined && (!Number.isInteger(mask) || mask < 0 || mask > maximum32BitPayloadLength)) { + throw new Error('WebSocket masks must be unsigned 32-bit integers.'); + } +} + +function isControlOpcode(opcode: number): boolean { + return (opcode & 0b00001000) !== 0; +} + +function copyAndApplyWebSocketMask(payload: VSBuffer, mask: number): VSBuffer { + const maskedPayload = VSBuffer.alloc(payload.byteLength); + maskedPayload.set(payload); + applyWebSocketMask(maskedPayload, mask); + return maskedPayload; +} diff --git a/src/vs/base/parts/ipc/node/ipc.net.ts b/src/vs/base/parts/ipc/node/ipc.net.ts index aa5b367355f65b..d97410ac0850ca 100644 --- a/src/vs/base/parts/ipc/node/ipc.net.ts +++ b/src/vs/base/parts/ipc/node/ipc.net.ts @@ -16,7 +16,8 @@ import { join } from '../../../common/path.js'; import { Platform, platform } from '../../../common/platform.js'; import { generateUuid } from '../../../common/uuid.js'; import { ClientConnectionEvent, IPCServer } from '../common/ipc.js'; -import { ChunkStream, Client, ISocket, Protocol, SocketCloseEvent, SocketCloseEventType, SocketDiagnostics, SocketDiagnosticsEventType } from '../common/ipc.net.js'; +import { Client, ISocket, Protocol, SocketCloseEvent, SocketCloseEventType, SocketDiagnostics, SocketDiagnosticsEventType } from '../common/ipc.net.js'; +import { encodeWebSocketFrame, WebSocketFrameParser, WebSocketOpcode } from '../common/webSocketFraming.js'; export function upgradeToISocket(req: http.IncomingMessage, socket: Socket, { debugLabel, @@ -266,7 +267,6 @@ export class NodeSocket implements ISocket { } const enum Constants { - MinHeaderByteSize = 2, /** * If we need to write a large buffer, we will split it into 256KB chunks and * send each chunk as a websocket message. This is to prevent that the sending @@ -277,20 +277,13 @@ const enum Constants { MaxWebSocketMessageLength = 256 * 1024 // 256 KB } -const enum ReadState { - PeekHeader = 1, - ReadHeader = 2, - ReadBody = 3, - Fin = 4 -} - interface ISocketTracer { traceSocketEvent(type: SocketDiagnosticsEventType, data?: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView | unknown): void; } interface FrameOptions { compressed: boolean; - opcode: number; + opcode: WebSocketOpcode; } /** @@ -300,21 +293,12 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT public readonly socket: NodeSocket; private readonly _flowManager: WebSocketFlowManager; - private readonly _incomingData: ChunkStream; + private readonly _frameParser = new WebSocketFrameParser({ unmaskInPlace: true }); private readonly _onData = this._register(new Emitter()); private readonly _onClose = this._register(new Emitter()); private readonly _maxSocketMessageLength: number; private _isEnded = false; - - private readonly _state = { - state: ReadState.PeekHeader, - readLen: Constants.MinHeaderByteSize, - fin: 0, - compressed: false, - firstFrameOfMessage: true, - mask: 0, - opcode: 0 - }; + private _compressedMessage = false; public get permessageDeflate(): boolean { return this._flowManager.permessageDeflate; @@ -367,7 +351,6 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT error: err }); })); - this._incomingData = new ChunkStream(); this._register(this.socket.onData(data => this._acceptChunk(data))); this._register(this.socket.onClose(async (e) => { // Delay surfacing the close event until the async inflating is done @@ -418,7 +401,7 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT let start = 0; while (start < buffer.byteLength) { - this._flowManager.writeMessage(buffer.slice(start, Math.min(start + this._maxSocketMessageLength, buffer.byteLength)), { compressed: true, opcode: 0x02 /* Binary frame */ }); + this._flowManager.writeMessage(buffer.slice(start, Math.min(start + this._maxSocketMessageLength, buffer.byteLength)), { compressed: true, opcode: WebSocketOpcode.Binary }); start += this._maxSocketMessageLength; } } @@ -430,41 +413,7 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT } this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketWrite, buffer); - let headerLen = Constants.MinHeaderByteSize; - if (buffer.byteLength < 126) { - headerLen += 0; - } else if (buffer.byteLength < 2 ** 16) { - headerLen += 2; - } else { - headerLen += 8; - } - const header = VSBuffer.alloc(headerLen); - - // The RSV1 bit indicates a compressed frame - const compressedFlag = compressed ? 0b01000000 : 0; - const opcodeFlag = opcode & 0b00001111; - header.writeUInt8(0b10000000 | compressedFlag | opcodeFlag, 0); - if (buffer.byteLength < 126) { - header.writeUInt8(buffer.byteLength, 1); - } else if (buffer.byteLength < 2 ** 16) { - header.writeUInt8(126, 1); - let offset = 1; - header.writeUInt8((buffer.byteLength >>> 8) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 0) & 0b11111111, ++offset); - } else { - header.writeUInt8(127, 1); - let offset = 1; - header.writeUInt8(0, ++offset); - header.writeUInt8(0, ++offset); - header.writeUInt8(0, ++offset); - header.writeUInt8(0, ++offset); - header.writeUInt8((buffer.byteLength >>> 24) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 16) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 8) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 0) & 0b11111111, ++offset); - } - - this.socket.write(VSBuffer.concat([header, buffer])); + this.socket.write(encodeWebSocketFrame(buffer, { compressed, opcode })); } public end(): void { @@ -473,100 +422,25 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT } private _acceptChunk(data: VSBuffer): void { - if (data.byteLength === 0) { - return; - } - - this._incomingData.acceptChunk(data); - - while (this._incomingData.byteLength >= this._state.readLen) { - - if (this._state.state === ReadState.PeekHeader) { - // peek to see if we can read the entire header - const peekHeader = this._incomingData.peek(this._state.readLen); - const firstByte = peekHeader.readUInt8(0); - const finBit = (firstByte & 0b10000000) >>> 7; - const rsv1Bit = (firstByte & 0b01000000) >>> 6; - const opcode = (firstByte & 0b00001111); - - const secondByte = peekHeader.readUInt8(1); - const hasMask = (secondByte & 0b10000000) >>> 7; - const len = (secondByte & 0b01111111); - - this._state.state = ReadState.ReadHeader; - this._state.readLen = Constants.MinHeaderByteSize + (hasMask ? 4 : 0) + (len === 126 ? 2 : 0) + (len === 127 ? 8 : 0); - this._state.fin = finBit; - if (this._state.firstFrameOfMessage) { - // if the frame is compressed, the RSV1 bit is set only for the first frame of the message - this._state.compressed = Boolean(rsv1Bit); - } - this._state.firstFrameOfMessage = Boolean(finBit); - this._state.mask = 0; - this._state.opcode = opcode; - - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketPeekedHeader, { headerSize: this._state.readLen, compressed: this._state.compressed, fin: this._state.fin, opcode: this._state.opcode }); - - } else if (this._state.state === ReadState.ReadHeader) { - // read entire header - const header = this._incomingData.read(this._state.readLen); - const secondByte = header.readUInt8(1); - const hasMask = (secondByte & 0b10000000) >>> 7; - let len = (secondByte & 0b01111111); - - let offset = 1; - if (len === 126) { - len = ( - header.readUInt8(++offset) * 2 ** 8 - + header.readUInt8(++offset) - ); - } else if (len === 127) { - len = ( - header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 2 ** 24 - + header.readUInt8(++offset) * 2 ** 16 - + header.readUInt8(++offset) * 2 ** 8 - + header.readUInt8(++offset) - ); - } - - let mask = 0; - if (hasMask) { - mask = ( - header.readUInt8(++offset) * 2 ** 24 - + header.readUInt8(++offset) * 2 ** 16 - + header.readUInt8(++offset) * 2 ** 8 - + header.readUInt8(++offset) - ); - } - - this._state.state = ReadState.ReadBody; - this._state.readLen = len; - this._state.mask = mask; - - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketPeekedHeader, { bodySize: this._state.readLen, compressed: this._state.compressed, fin: this._state.fin, mask: this._state.mask, opcode: this._state.opcode }); - - } else if (this._state.state === ReadState.ReadBody) { - // read body - - const body = this._incomingData.read(this._state.readLen); - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketReadData, body); - - unmask(body, this._state.mask); - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketUnmaskedData, body); + for (const frame of this._frameParser.acceptChunk(data)) { + const compressed = frame.opcode === WebSocketOpcode.Continuation ? this._compressedMessage : frame.compressed; + if (frame.opcode === WebSocketOpcode.Text || frame.opcode === WebSocketOpcode.Binary) { + this._compressedMessage = frame.compressed; + } - this._state.state = ReadState.PeekHeader; - this._state.readLen = Constants.MinHeaderByteSize; - this._state.mask = 0; + this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketPeekedHeader, { bodySize: frame.payload.byteLength, compressed, fin: Number(frame.final), mask: frame.mask, opcode: frame.opcode }); + this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketReadData, frame.payload); + if (frame.mask !== undefined) { + this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketUnmaskedData, frame.payload); + } - if (this._state.opcode <= 0x02 /* Continuation frame or Text frame or binary frame */) { - this._flowManager.acceptFrame(body, this._state.compressed, !!this._state.fin); - } else if (this._state.opcode === 0x09 /* Ping frame */) { - // Ping frames could be send by some browsers e.g. Firefox - this._flowManager.writeMessage(body, { compressed: false, opcode: 0x0A /* Pong frame */ }); + if (frame.opcode === WebSocketOpcode.Continuation || frame.opcode === WebSocketOpcode.Text || frame.opcode === WebSocketOpcode.Binary) { + this._flowManager.acceptFrame(frame.payload, compressed, frame.final); + if (frame.final) { + this._compressedMessage = false; } + } else if (frame.opcode === WebSocketOpcode.Ping) { + this._flowManager.writeMessage(frame.payload, { compressed: false, opcode: WebSocketOpcode.Pong }); } } } @@ -858,31 +732,6 @@ class ZlibDeflateStream extends Disposable { } } -function unmask(buffer: VSBuffer, mask: number): void { - if (mask === 0) { - return; - } - const cnt = buffer.byteLength >>> 2; - for (let i = 0; i < cnt; i++) { - const v = buffer.readUInt32BE(i * 4); - buffer.writeUInt32BE(v ^ mask, i * 4); - } - const offset = cnt * 4; - const bytesLeft = buffer.byteLength - offset; - const m3 = (mask >>> 24) & 0b11111111; - const m2 = (mask >>> 16) & 0b11111111; - const m1 = (mask >>> 8) & 0b11111111; - if (bytesLeft >= 1) { - buffer.writeUInt8(buffer.readUInt8(offset) ^ m3, offset); - } - if (bytesLeft >= 2) { - buffer.writeUInt8(buffer.readUInt8(offset + 1) ^ m2, offset + 1); - } - if (bytesLeft >= 3) { - buffer.writeUInt8(buffer.readUInt8(offset + 2) ^ m1, offset + 2); - } -} - // Read this before there's any chance it is overwritten // Related to https://github.com/microsoft/vscode/issues/30624 export const XDG_RUNTIME_DIR = process.env['XDG_RUNTIME_DIR']; diff --git a/src/vs/base/parts/ipc/test/common/webSocketFraming.test.ts b/src/vs/base/parts/ipc/test/common/webSocketFraming.test.ts new file mode 100644 index 00000000000000..fc337ddcdfc629 --- /dev/null +++ b/src/vs/base/parts/ipc/test/common/webSocketFraming.test.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../common/buffer.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../test/common/utils.js'; +import { encodeWebSocketFrame, WebSocketFrameParser, WebSocketOpcode } from '../../common/webSocketFraming.js'; + +suite('WebSocket framing', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('encodes and parses a masked client frame without mutating its payload', () => { + const payload = VSBuffer.fromString('Hello'); + const encoded = encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Text, mask: 0x01020304 }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + encoded: Array.from(encoded.buffer), + payload: payload.toString(), + frame: { + final: frame.final, + compressed: frame.compressed, + opcode: frame.opcode, + payload: frame.payload.toString(), + mask: frame.mask, + }, + }, { + encoded: [0x81, 0x85, 0x01, 0x02, 0x03, 0x04, 0x49, 0x67, 0x6f, 0x68, 0x6e], + payload: 'Hello', + frame: { + final: true, + compressed: false, + opcode: WebSocketOpcode.Text, + payload: 'Hello', + mask: 0x01020304, + }, + }); + }); + + test('masks a three-byte payload using every remainder byte', () => { + const payload = VSBuffer.fromByteArray([0xaa, 0xbb, 0xcc]); + const encoded = encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Binary, mask: 0x12345678 }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + encoded: Array.from(encoded.buffer), + payload: Array.from(frame.payload.buffer), + }, { + encoded: [0x82, 0x83, 0x12, 0x34, 0x56, 0x78, 0xb8, 0x8f, 0x9a], + payload: [0xaa, 0xbb, 0xcc], + }); + }); + + test('can unmask owned payload buffers in place', () => { + const encoded = encodeWebSocketFrame(VSBuffer.fromString('owned'), { opcode: WebSocketOpcode.Text, mask: 0x12345678 }); + const frame = new WebSocketFrameParser({ unmaskInPlace: true }).acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + payload: frame.payload.toString(), + wirePayloadAfterParsing: encoded.slice(6).toString(), + }, { + payload: 'owned', + wirePayloadAfterParsing: 'owned', + }); + }); + + test('preserves a present zero-valued mask', () => { + const encoded = encodeWebSocketFrame(VSBuffer.fromString('zero'), { opcode: WebSocketOpcode.Text, mask: 0 }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + maskBit: encoded.readUInt8(1) & 0b10000000, + maskBytes: Array.from(encoded.slice(2, 6).buffer), + payload: frame.payload.toString(), + mask: frame.mask, + }, { + maskBit: 0b10000000, + maskBytes: [0, 0, 0, 0], + payload: 'zero', + mask: 0, + }); + }); + + test('accepts frames across chunk boundaries and in coalesced chunks', () => { + const first = encodeWebSocketFrame(VSBuffer.fromString('first'), { opcode: WebSocketOpcode.Text }); + const second = encodeWebSocketFrame(VSBuffer.fromString('second'), { opcode: WebSocketOpcode.Text }); + const parser = new WebSocketFrameParser(); + + const firstPart = parser.acceptChunk(first.slice(0, 3)); + const remaining = parser.acceptChunk(VSBuffer.concat([first.slice(3), second])); + + assert.deepStrictEqual({ + firstPart: firstPart.length, + remaining: remaining.map(frame => frame.payload.toString()), + }, { + firstPart: 0, + remaining: ['first', 'second'], + }); + }); + + for (const length of [125, 126, 65_535, 65_536]) { + test(`encodes and parses payload length ${length}`, () => { + const payload = VSBuffer.alloc(length); + for (let index = 0; index < payload.byteLength; index++) { + payload.writeUInt8(index, index); + } + + const encoded = encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Binary }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + header: Array.from(encoded.slice(0, encoded.byteLength - payload.byteLength).buffer), + length: frame.payload.byteLength, + first: frame.payload.readUInt8(0), + last: frame.payload.readUInt8(frame.payload.byteLength - 1), + }, { + header: length < 126 + ? [0x82, length] + : length < 2 ** 16 + ? [0x82, 126, (length >>> 8) & 0xff, length & 0xff] + : [0x82, 127, 0, 0, 0, 0, (length >>> 24) & 0xff, (length >>> 16) & 0xff, (length >>> 8) & 0xff, length & 0xff], + length, + first: 0, + last: (length - 1) & 0xff, + }); + }); + } + + test('rejects invalid control frames, reserved opcodes, and unsupported lengths', () => { + assert.throws(() => encodeWebSocketFrame(VSBuffer.alloc(0), { opcode: WebSocketOpcode.Ping, final: false })); + assert.throws(() => new WebSocketFrameParser().acceptChunk(VSBuffer.fromByteArray([0x83, 0x00]))); + assert.throws(() => new WebSocketFrameParser().acceptChunk(VSBuffer.fromByteArray([0x89, 0x7e, 0x00, 0x7e, ...new Array(126).fill(0)]))); + assert.throws(() => new WebSocketFrameParser().acceptChunk(VSBuffer.fromByteArray([0x82, 0x7f, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]))); + }); + + test('rejects frames over the configured payload limit after reading the header', () => { + const encoded = encodeWebSocketFrame(VSBuffer.fromString('too large'), { opcode: WebSocketOpcode.Text }); + assert.throws( + () => new WebSocketFrameParser({ maxPayloadLength: 4 }).acceptChunk(encoded.slice(0, 2)), + /configured limit of 4/, + ); + }); +}); diff --git a/src/vs/platform/agentHost/common/tunnelMessageSocket.ts b/src/vs/platform/agentHost/common/tunnelMessageSocket.ts index c733b75e3ad1c5..239970961516db 100644 --- a/src/vs/platform/agentHost/common/tunnelMessageSocket.ts +++ b/src/vs/platform/agentHost/common/tunnelMessageSocket.ts @@ -27,61 +27,15 @@ export interface ITunnelSocketCloseEvent { /** The subset of a tunnel relay duplex stream used to perform an HTTP upgrade. */ export interface ITunnelDuplexStream { - readonly remoteAddress?: string; on(event: 'data', listener: (chunk: Uint8Array) => void): void; on(event: 'error', listener: (err: Error) => void): void; on(event: 'close', listener: (hadError?: boolean) => void): void; - on(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; + on(event: 'end', listener: () => void): void; removeListener(event: 'data', listener: (chunk: Uint8Array) => void): void; removeListener(event: 'error', listener: (err: Error) => void): void; removeListener(event: 'close', listener: (hadError?: boolean) => void): void; - removeListener(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; - removeAllListeners(event: 'error'): void; + removeListener(event: 'end', listener: () => void): void; write(chunk: Uint8Array | string): boolean; end(): void; destroy(): void; - pause(): void; - resume(): void; -} - -/** A socket-shaped view that supplies TCP methods expected by the framing implementation. */ -export interface IWebSocketDuplexStream extends ITunnelDuplexStream { - write(chunk: Uint8Array | string, callback?: (error?: Error) => void): boolean; - setNoDelay(enable: boolean): void; - setTimeout(timeout: number): void; - setKeepAlive(enable: boolean, initialDelay?: number): void; -} - -/** Configuration consumed by the bundled `WebSocketConnection` framing implementation. */ -export interface IWebSocketConnectionConfig { - readonly maxReceivedFrameSize: number; - readonly maxReceivedMessageSize: number; - readonly fragmentOutgoingMessages: boolean; - readonly fragmentationThreshold: number; - readonly webSocketVersion: 13; - readonly assembleFragments: boolean; - readonly disableNagleAlgorithm: boolean; - readonly closeTimeout: number; -} - -/** A message emitted by the bundled `WebSocketConnection` framing implementation. */ -export type WebSocketConnectionMessage = { readonly type: 'utf8'; readonly utf8Data: string } | { readonly type: 'binary'; readonly binaryData: Uint8Array }; - -/** The event-emitter surface used by the WebSocket-over-duplex adapter. */ -export interface IWebSocketConnection { - _addSocketEventListeners(): void; - handleSocketData(data: Uint8Array): void; - on(event: 'message', listener: (message: WebSocketConnectionMessage) => void): void; - on(event: 'close', listener: (code: number, reason: string) => void): void; - on(event: 'error', listener: (error: Error) => void): void; - removeListener(event: 'message', listener: (message: WebSocketConnectionMessage) => void): void; - removeListener(event: 'close', listener: (code: number, reason: string) => void): void; - removeListener(event: 'error', listener: (error: Error) => void): void; - send(data: string): void; - close(): void; -} - -/** Constructs the bundled `WebSocketConnection` framing implementation. */ -export interface WebSocketConnectionCtor { - new(stream: IWebSocketDuplexStream, extensions: [], protocol: string | null, maskOutgoingPackets: boolean, config: IWebSocketConnectionConfig): IWebSocketConnection; } diff --git a/src/vs/platform/agentHost/common/webSocketOverDuplex.ts b/src/vs/platform/agentHost/common/webSocketOverDuplex.ts index 2c662f4378582a..ad9325c24c5071 100644 --- a/src/vs/platform/agentHost/common/webSocketOverDuplex.ts +++ b/src/vs/platform/agentHost/common/webSocketOverDuplex.ts @@ -4,31 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { TimeoutTimer } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; -import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent, IWebSocketConnection, IWebSocketConnectionConfig, IWebSocketDuplexStream, WebSocketConnectionCtor, WebSocketConnectionMessage } from './tunnelMessageSocket.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { encodeWebSocketFrame, type IWebSocketFrame, WebSocketFrameParser, WebSocketFrameTooLargeError, WebSocketOpcode } from '../../../base/parts/ipc/common/webSocketFraming.js'; +import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent } from './tunnelMessageSocket.js'; const websocketAcceptGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const headerTerminator = VSBuffer.fromString('\r\n\r\n').buffer; -const websocketConnectionConfig: IWebSocketConnectionConfig = { - maxReceivedFrameSize: 0x100000, - maxReceivedMessageSize: 0x800000, - fragmentOutgoingMessages: true, - fragmentationThreshold: 0x4000, - webSocketVersion: 13, - assembleFragments: true, - disableNagleAlgorithm: true, - closeTimeout: 5000, -}; - +const defaultMaxFramePayloadLength = 0x100000; +const defaultMaxMessagePayloadLength = 0x800000; +const defaultCloseTimeoutMs = 5000; /** Options used to establish a WebSocket connection over an existing tunnel stream. */ export interface IWebSocketOverDuplexOptions { /** Request path, e.g. '/agent-host/select' or '/?tkn=abc'. */ readonly path: string; /** Host header value; the tunnel stream is already pointed at the right port. */ readonly host?: string; - /** Injected WebSocketConnection constructor from the lazily-loaded browser bundle. */ - readonly webSocketConnectionCtor: WebSocketConnectionCtor; + /** Maximum accepted frame payload length. */ + readonly maxFramePayloadLength?: number; + /** Maximum accepted assembled message payload length. */ + readonly maxMessagePayloadLength?: number; + /** Time to wait for the peer to complete a close handshake. */ + readonly closeTimeoutMs?: number; } /** Opens a framed WebSocket connection over an already-connected tunnel stream. */ @@ -62,11 +60,14 @@ export async function connectWebSocketOverDuplex( } responseReader.detach(); - const connection = new options.webSocketConnectionCtor(new WebSocketDuplexStreamAdapter(stream), [], null, true, websocketConnectionConfig); - const socket = new TunnelMessageSocket(stream, connection); - connection._addSocketEventListeners(); + const socket = new TunnelMessageSocket( + stream, + options.maxFramePayloadLength ?? defaultMaxFramePayloadLength, + options.maxMessagePayloadLength ?? defaultMaxMessagePayloadLength, + options.closeTimeoutMs ?? defaultCloseTimeoutMs, + ); for (const chunk of responseReader.remainingChunks(headerEnd)) { - connection.handleSocketData(chunk); + socket.acceptChunk(chunk); } return socket; } catch (error) { @@ -101,100 +102,6 @@ export async function createWebSocketAccept(key: string): Promise { return encodeBase64(VSBuffer.wrap(new Uint8Array(digest))); } -/** Adapts a tunnel duplex stream to the TCP-like socket surface required by `WebSocketConnection`. */ -class WebSocketDuplexStreamAdapter implements IWebSocketDuplexStream { - private _ended = false; - private _destroyed = false; - - constructor(private readonly _stream: ITunnelDuplexStream) { - } - - get remoteAddress(): string | undefined { - return this._stream.remoteAddress; - } - - on(event: 'data', listener: (chunk: Uint8Array) => void): void; - on(event: 'error', listener: (err: Error) => void): void; - on(event: 'close', listener: (hadError?: boolean) => void): void; - on(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; - on(event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', listener: ((chunk: Uint8Array) => void) | ((err: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { - switch (event) { - case 'data': - this._stream.on(event, listener as (chunk: Uint8Array) => void); - break; - case 'error': - this._stream.on(event, listener as (err: Error) => void); - break; - case 'close': - this._stream.on(event, listener as (hadError?: boolean) => void); - break; - default: - this._stream.on(event, listener as () => void); - } - } - - removeListener(event: 'data', listener: (chunk: Uint8Array) => void): void; - removeListener(event: 'error', listener: (err: Error) => void): void; - removeListener(event: 'close', listener: (hadError?: boolean) => void): void; - removeListener(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; - removeListener(event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', listener: ((chunk: Uint8Array) => void) | ((err: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { - switch (event) { - case 'data': - this._stream.removeListener(event, listener as (chunk: Uint8Array) => void); - break; - case 'error': - this._stream.removeListener(event, listener as (err: Error) => void); - break; - case 'close': - this._stream.removeListener(event, listener as (hadError?: boolean) => void); - break; - default: - this._stream.removeListener(event, listener as () => void); - } - } - - removeAllListeners(event: 'error'): void { - this._stream.removeAllListeners(event); - } - - write(chunk: Uint8Array | string, callback?: (error?: Error) => void): boolean { - const written = this._stream.write(chunk); - callback?.(); - return written; - } - - end(): void { - if (!this._ended) { - this._ended = true; - this._stream.end(); - } - } - - destroy(): void { - if (!this._destroyed) { - this._destroyed = true; - this._stream.destroy(); - } - } - - pause(): void { - this._stream.pause(); - } - - resume(): void { - this._stream.resume(); - } - - setNoDelay(_enable: boolean): void { - } - - setTimeout(_timeout: number): void { - } - - setKeepAlive(_enable: boolean, _initialDelay?: number): void { - } -} - /** A parsed HTTP WebSocket upgrade response. */ interface IUpgradeResponse { readonly status: number; @@ -323,7 +230,7 @@ function findSequence(bytes: Uint8Array, sequence: Uint8Array): number { return -1; } -/** Adapts the bundled WebSocket framing implementation to the tunnel socket contract. */ +/** Adapts shared RFC 6455 framing to the tunnel socket contract. */ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { private readonly _onDidReceiveMessage = this._register(new Emitter({ onDidAddFirstListener: () => this.flushPendingMessages(), @@ -332,40 +239,147 @@ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose: Event = this._onDidClose.event; private readonly _pendingMessages: string[] = []; + private readonly _frameParser: WebSocketFrameParser; + private _fragmentedMessage: VSBuffer[] | undefined; + private _fragmentedMessageLength = 0; private _closed = false; + private _closeSent = false; + private _streamEnded = false; + private _streamDestroyed = false; + private readonly _closeTimer = this._register(new TimeoutTimer()); constructor( private readonly _stream: ITunnelDuplexStream, - private readonly _connection: IWebSocketConnection, + maxFramePayloadLength: number, + private readonly _maxMessagePayloadLength: number, + private readonly _closeTimeoutMs: number, ) { super(); - const onMessage = (message: WebSocketConnectionMessage) => this.acceptMessage(message); - const onClose = (code: number, reason: string) => this.finishClose({ code, reason }); - const onError = (error: Error) => this.finishClose({ error }); - this._connection.on('message', onMessage); - this._connection.on('close', onClose); - this._connection.on('error', onError); - this._register(toDisposable(() => this._connection.removeListener('message', onMessage))); - this._register(toDisposable(() => this._connection.removeListener('close', onClose))); - this._register(toDisposable(() => this._connection.removeListener('error', onError))); + this._frameParser = new WebSocketFrameParser({ maxPayloadLength: maxFramePayloadLength }); + const onData = (data: Uint8Array) => this.acceptChunk(data); + const onError = (error: Error) => this.fail(error, 1002); + const onEnd = () => this.finishClose({}); + const onClose = () => this.finishClose({}); + this._stream.on('data', onData); + this._stream.on('error', onError); + this._stream.on('end', onEnd); + this._stream.on('close', onClose); + this._register({ + dispose: () => { + this._stream.removeListener('data', onData); + this._stream.removeListener('error', onError); + this._stream.removeListener('end', onEnd); + this._stream.removeListener('close', onClose); + } + }); } send(data: string): void { - this._connection.send(data); + if (!this._closed) { + this.writeFrame(VSBuffer.fromString(data), WebSocketOpcode.Text); + } } close(): void { - this._connection.close(); + if (!this._closed) { + this.sendClose(1000, ''); + this._closeTimer.setIfNotSet(() => { + const error = new Error(`WebSocket close handshake timed out after ${this._closeTimeoutMs}ms.`); + this.finishClose({ error }); + this.endStream(); + this.destroyStream(); + }, this._closeTimeoutMs); + } } override dispose(): void { - this._connection.close(); - this._stream.destroy(); + this.close(); + this.destroyStream(); super.dispose(); } - private acceptMessage(message: WebSocketConnectionMessage): void { - const data = message.type === 'utf8' ? message.utf8Data : new TextDecoder().decode(message.binaryData); + acceptChunk(data: Uint8Array): void { + try { + for (const frame of this._frameParser.acceptChunk(VSBuffer.wrap(data))) { + this.acceptFrame(frame); + } + } catch (error) { + if (error instanceof WebSocketFrameTooLargeError) { + this.fail(error, 1009); + } else { + this.fail(new Error('Received an invalid WebSocket frame.'), 1002); + } + } + } + + private acceptFrame(frame: IWebSocketFrame): void { + if (this._closed) { + return; + } + if (frame.mask !== undefined) { + this.fail(new Error('Received a masked WebSocket frame from the server.'), 1002); + return; + } + if (frame.compressed) { + this.fail(new Error('Received an unsupported compressed WebSocket frame.'), 1002); + return; + } + + switch (frame.opcode) { + case WebSocketOpcode.Text: + if (this._fragmentedMessage) { + this.fail(new Error('Received a WebSocket text frame before a fragmented message was complete.'), 1002); + } else if (frame.final) { + this.acceptText(frame.payload); + } else { + this._fragmentedMessage = [frame.payload]; + this._fragmentedMessageLength = frame.payload.byteLength; + this.ensureMessageWithinLimit(); + } + break; + case WebSocketOpcode.Continuation: + if (!this._fragmentedMessage) { + this.fail(new Error('Received a WebSocket continuation frame without a preceding text frame.'), 1002); + } else { + this._fragmentedMessage.push(frame.payload); + this._fragmentedMessageLength += frame.payload.byteLength; + if (!this.ensureMessageWithinLimit()) { + return; + } + if (frame.final) { + const payload = VSBuffer.concat(this._fragmentedMessage); + this._fragmentedMessage = undefined; + this._fragmentedMessageLength = 0; + this.acceptText(payload); + } + } + break; + case WebSocketOpcode.Binary: + this.fail(new Error('Received an unsupported binary WebSocket message.'), 1003); + break; + case WebSocketOpcode.Ping: + this.writeFrame(frame.payload, WebSocketOpcode.Pong); + break; + case WebSocketOpcode.Close: + this.acceptClose(frame.payload); + break; + case WebSocketOpcode.Pong: + break; + } + } + + private acceptText(payload: VSBuffer): void { + if (payload.byteLength > this._maxMessagePayloadLength) { + this.fail(new Error(`WebSocket message payload length ${payload.byteLength} exceeds the configured limit of ${this._maxMessagePayloadLength}.`), 1009); + return; + } + let data: string; + try { + data = new TextDecoder('utf-8', { fatal: true }).decode(payload.buffer); + } catch { + this.fail(new Error('Received invalid UTF-8 WebSocket text.'), 1007); + return; + } if (this._onDidReceiveMessage.hasListeners()) { this._onDidReceiveMessage.fire(data); } else { @@ -373,6 +387,38 @@ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { } } + private acceptClose(payload: VSBuffer): void { + if (payload.byteLength === 1) { + this.fail(new Error('Received a WebSocket close frame with an invalid payload.'), 1002); + return; + } + + let event: ITunnelSocketCloseEvent = {}; + if (payload.byteLength >= 2) { + const code = payload.readUInt8(0) * 2 ** 8 + payload.readUInt8(1); + if (!isValidCloseCode(code)) { + this.fail(new Error(`Received an invalid WebSocket close code ${code}.`), 1002); + return; + } + try { + event = { + code, + reason: new TextDecoder('utf-8', { fatal: true }).decode(payload.slice(2).buffer), + }; + } catch { + this.fail(new Error('Received invalid UTF-8 WebSocket close reason.'), 1007); + return; + } + } + + if (!this._closeSent) { + this.writeFrame(payload, WebSocketOpcode.Close); + this._closeSent = true; + } + this.finishClose(event); + this.endStream(); + } + private flushPendingMessages(): void { while (this._pendingMessages.length > 0) { this._onDidReceiveMessage.fire(this._pendingMessages.shift()!); @@ -380,9 +426,67 @@ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { } private finishClose(event: ITunnelSocketCloseEvent): void { + this._closeTimer.cancel(); if (!this._closed) { this._closed = true; this._onDidClose.fire(event); } } + + private fail(error: Error, closeCode: number): void { + if (this._closed) { + return; + } + this.sendClose(closeCode, ''); + this.finishClose({ error }); + this.endStream(); + } + + private sendClose(code: number, reason: string): void { + if (this._closeSent) { + return; + } + const reasonPayload = VSBuffer.fromString(reason); + const payload = VSBuffer.alloc(2 + reasonPayload.byteLength); + payload.writeUInt8(code >>> 8, 0); + payload.writeUInt8(code, 1); + payload.set(reasonPayload, 2); + this.writeFrame(payload, WebSocketOpcode.Close); + this._closeSent = true; + } + + private ensureMessageWithinLimit(): boolean { + if (this._fragmentedMessageLength > this._maxMessagePayloadLength) { + this.fail(new Error(`WebSocket message payload length ${this._fragmentedMessageLength} exceeds the configured limit of ${this._maxMessagePayloadLength}.`), 1009); + return false; + } + return true; + } + + private writeFrame(payload: VSBuffer, opcode: WebSocketOpcode): void { + if (this._closed) { + return; + } + const maskBytes = crypto.getRandomValues(new Uint8Array(4)); + const mask = maskBytes[0] * 2 ** 24 + maskBytes[1] * 2 ** 16 + maskBytes[2] * 2 ** 8 + maskBytes[3]; + this._stream.write(encodeWebSocketFrame(payload, { opcode, mask }).buffer); + } + + private endStream(): void { + if (!this._streamEnded) { + this._streamEnded = true; + this._stream.end(); + } + } + + private destroyStream(): void { + if (!this._streamDestroyed) { + this._streamDestroyed = true; + this._stream.destroy(); + } + } +} + +function isValidCloseCode(code: number): boolean { + return code === 1000 || (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) || (code >= 3000 && code <= 4999); } diff --git a/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts index 046eccbe6f08ca..55493135a313ee 100644 --- a/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts +++ b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts @@ -25,19 +25,16 @@ class FakeStream implements ITunnelDuplexStream { on(_event: 'data', _listener: (data: Uint8Array) => void): this; on(_event: 'error', _listener: (error: Error) => void): this; on(_event: 'close', _listener: (hadError?: boolean) => void): this; - on(_event: 'end' | 'drain' | 'pause' | 'resume', _listener: () => void): this; - on(_event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): this { + on(_event: 'end', _listener: () => void): this; + on(_event: 'data' | 'error' | 'end' | 'close', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): this { return this; } removeListener(_event: 'data', _listener: (data: Uint8Array) => void): void; removeListener(_event: 'error', _listener: (error: Error) => void): void; removeListener(_event: 'close', _listener: (hadError?: boolean) => void): void; - removeListener(_event: 'end' | 'drain' | 'pause' | 'resume', _listener: () => void): void; - removeListener(_event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { - } - - removeAllListeners(_event: 'error'): void { + removeListener(_event: 'end', _listener: () => void): void; + removeListener(_event: 'data' | 'error' | 'end' | 'close', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { } write(_data: string | Uint8Array): boolean { @@ -50,12 +47,6 @@ class FakeStream implements ITunnelDuplexStream { destroy(): void { } - pause(): void { - } - - resume(): void { - } - } class FakeRelayClient implements ITunnelRelayClient { diff --git a/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts b/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts index eb282f943666af..ca006f7e491834 100644 --- a/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts +++ b/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts @@ -5,15 +5,14 @@ import assert from 'assert'; import { EventEmitter } from 'events'; -import { createRequire } from 'module'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { Event } from '../../../../base/common/event.js'; -import { hasKey } from '../../../../base/common/types.js'; +import { encodeWebSocketFrame, type IWebSocketFrame, WebSocketFrameParser, WebSocketOpcode } from '../../../../base/parts/ipc/common/webSocketFraming.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { connectWebSocketOverDuplex, createWebSocketAccept } from '../../common/webSocketOverDuplex.js'; -import type { ITunnelDuplexStream, IWebSocketDuplexStream, WebSocketConnectionCtor } from '../../common/tunnelMessageSocket.js'; +import { connectWebSocketOverDuplex, createWebSocketAccept, type IWebSocketOverDuplexOptions } from '../../common/webSocketOverDuplex.js'; +import type { ITunnelDuplexStream } from '../../common/tunnelMessageSocket.js'; -const WebSocketConnection = createRequire(import.meta.url)('websocket/lib/WebSocketConnection') as WebSocketConnectionCtor; const websocketAcceptGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; suite('connectWebSocketOverDuplex', () => { @@ -45,36 +44,6 @@ suite('connectWebSocketOverDuplex', () => { ); }); - test('adapts a bare tunnel duplex stream without TCP socket methods', async () => { - const stream = new FakeDuplexStream(); - const socketPromise = connect(stream); - stream.push(await createUpgradeResponse(stream.request)); - const socket = await socketPromise; - const socketLike = stream as Partial; - assert.deepStrictEqual({ - socketCreated: !!socket, - hasSetNoDelay: hasKey(socketLike, { setNoDelay: true }), - hasSetTimeout: hasKey(socketLike, { setTimeout: true }), - hasSetKeepAlive: hasKey(socketLike, { setKeepAlive: true }), - }, { - socketCreated: true, - hasSetNoDelay: false, - hasSetTimeout: false, - hasSetKeepAlive: false, - }); - store.add(socket); - }); - - test('does not recurse when ending a re-entrant tunnel stream', async () => { - const stream = new ReentrantEndDuplexStream(); - const socketPromise = connect(stream); - stream.push(await createUpgradeResponse(stream.request)); - const socket = store.add(await socketPromise); - stream.end(); - - assert.deepStrictEqual({ endCalls: stream.endCalls, socketCreated: !!socket }, { endCalls: 2, socketCreated: true }); - }); - test('rejects a non-101 upgrade response', async () => { const stream = new FakeDuplexStream(); const socketPromise = connect(stream); @@ -100,7 +69,7 @@ suite('connectWebSocketOverDuplex', () => { const stream = new FakeDuplexStream(); const socketPromise = connect(stream); const response = await createUpgradeResponse(stream.request); - stream.push(concat(response, createTextFrame('coalesced'))); + stream.push(concat(response, createFrame('coalesced'))); const socket = store.add(await socketPromise); const message = Event.toPromise(socket.onDidReceiveMessage); @@ -113,18 +82,288 @@ suite('connectWebSocketOverDuplex', () => { stream.push(await createUpgradeResponse(stream.request)); const socket = store.add(await socketPromise); const message = Event.toPromise(socket.onDidReceiveMessage); - stream.push(createTextFrame('round trip')); + stream.push(createFrame('round trip')); assert.deepStrictEqual([await message], ['round trip']); }); -}); -function connect(stream: FakeDuplexStream, path = '/', host?: string) { - return connectWebSocketOverDuplex(stream, { - path, - host, - webSocketConnectionCtor: WebSocketConnection, + test('masks outgoing text frames', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + socket.send('outbound'); + + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + mask: frame.mask !== undefined, + opcode: frame.opcode, + payload: frame.payload.toString(), + }, { + mask: true, + opcode: WebSocketOpcode.Text, + payload: 'outbound', + }); + }); + + test('assembles fragmented inbound text messages', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const message = Event.toPromise(socket.onDidReceiveMessage); + stream.push(concat( + createFrame('frag', { final: false }), + createFrame('mented', { opcode: WebSocketOpcode.Continuation }), + )); + + assert.strictEqual(await message, 'fragmented'); + }); + + test('replies to pings with a masked pong', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + store.add(await socketPromise); + stream.push(createFrame('keepalive', { opcode: WebSocketOpcode.Ping })); + + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + mask: frame.mask !== undefined, + opcode: frame.opcode, + payload: frame.payload.toString(), + }, { + mask: true, + opcode: WebSocketOpcode.Pong, + payload: 'keepalive', + }); + }); + + test('acknowledges close frames once and ends the stream', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + let closeCount = 0; + store.add(socket.onDidClose(() => closeCount++)); + const close = Event.toPromise(socket.onDidClose); + stream.push(createCloseFrame(1000, 'done')); + + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + close: await close, + closeCount, + endCalls: stream.endCalls, + mask: frame.mask !== undefined, + opcode: frame.opcode, + payload: Array.from(frame.payload.buffer), + }, { + close: { code: 1000, reason: 'done' }, + closeCount: 1, + endCalls: 1, + mask: true, + opcode: WebSocketOpcode.Close, + payload: [0x03, 0xe8, 0x64, 0x6f, 0x6e, 0x65], + }); }); + + test('forces the stream closed when the close handshake times out', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream, '/', undefined, { closeTimeoutMs: 1 }); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + socket.close(); + + const closed = await close; + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + error: closed.error?.message.includes('close handshake timed out'), + endCalls: stream.endCalls, + destroyCalls: stream.destroyCalls, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + destroyCalls: 1, + closeCode: 1000, + }); + }); + + test('closes when a frame exceeds the configured payload limit', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream, '/', undefined, { maxFramePayloadLength: 4 }); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(createFrame('12345')); + + const closed = await close; + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + error: closed.error?.message.includes('configured limit of 4'), + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + closeCode: 1009, + }); + }); + + test('closes when a fragmented message exceeds the configured payload limit', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream, '/', undefined, { maxFramePayloadLength: 4, maxMessagePayloadLength: 5 }); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(concat( + createFrame('abc', { final: false }), + createFrame('def', { opcode: WebSocketOpcode.Continuation }), + )); + + const closed = await close; + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + error: closed.error?.message.includes('configured limit of 5'), + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + closeCode: 1009, + }); + }); + + test('closes with an error for invalid UTF-8 text', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(Uint8Array.from([0x81, 0x01, 0xc3])); + + const [frame] = clientFrames(stream); + const closed = await close; + assert.deepStrictEqual({ + error: closed.error?.message.includes('invalid UTF-8'), + endCalls: stream.endCalls, + opcode: frame.opcode, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + opcode: WebSocketOpcode.Close, + closeCode: 1007, + }); + }); + + test('closes with an error for binary messages', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(encodeWebSocketFrame(VSBuffer.fromString('binary'), { opcode: WebSocketOpcode.Binary }).buffer); + + const [frame] = clientFrames(stream); + const closed = await close; + assert.deepStrictEqual({ + error: closed.error?.message.includes('binary'), + endCalls: stream.endCalls, + opcode: frame.opcode, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + opcode: WebSocketOpcode.Close, + closeCode: 1003, + }); + }); + + test('closes with a protocol error for masked server frames', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(encodeWebSocketFrame(VSBuffer.fromString('masked'), { opcode: WebSocketOpcode.Text, mask: 0x12345678 }).buffer); + + const frames = clientFrames(stream); + const [frame] = frames; + const closed = await close; + assert.deepStrictEqual({ + error: closed.error?.message.includes('masked WebSocket frame'), + endCalls: stream.endCalls, + outgoingFrames: frames.length, + mask: frame.mask !== undefined, + opcode: frame.opcode, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + outgoingFrames: 1, + mask: true, + opcode: WebSocketOpcode.Close, + closeCode: 1002, + }); + }); + + test('ignores coalesced frames after a protocol failure', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const messages: string[] = []; + let closeCount = 0; + store.add(socket.onDidReceiveMessage(message => messages.push(message))); + store.add(socket.onDidClose(() => closeCount++)); + const close = Event.toPromise(socket.onDidClose); + stream.push(concat( + encodeWebSocketFrame(VSBuffer.fromString('binary'), { opcode: WebSocketOpcode.Binary }).buffer, + createFrame('must not be delivered'), + )); + + const [frame] = clientFrames(stream); + await close; + assert.deepStrictEqual({ + closeCount, + messages, + outgoingFrames: clientFrames(stream).length, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + closeCount: 1, + messages: [], + outgoingFrames: 1, + closeCode: 1003, + }); + }); + + test('does not recurse when a re-entrant stream ends during a close reply', async () => { + const stream = new ReentrantEndDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + stream.push(createCloseFrame(1000, 'done')); + + assert.deepStrictEqual({ + endCalls: stream.endCalls, + socketCreated: !!socket, + }, { + endCalls: 1, + socketCreated: true, + }); + }); + + test('does not recurse when disposing a re-entrant tunnel stream', async () => { + const stream = new ReentrantDestroyDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = await socketPromise; + socket.dispose(); + + assert.strictEqual(stream.destroyCalls, 1); + }); +}); + +function connect(stream: FakeDuplexStream, path = '/', host?: string, options: Omit = {}) { + return connectWebSocketOverDuplex(stream, { path, host, ...options }); } async function createUpgradeResponse(request: string): Promise { @@ -148,9 +387,27 @@ function requestKey(request: string): string { return match[1]; } -function createTextFrame(message: string): Uint8Array { - const data = new TextEncoder().encode(message); - return Uint8Array.from([0x81, data.byteLength, ...data]); +function createFrame(message: string, options: { readonly final?: boolean; readonly opcode?: WebSocketOpcode } = {}): Uint8Array { + return encodeWebSocketFrame(VSBuffer.fromString(message), { + final: options.final, + opcode: options.opcode ?? WebSocketOpcode.Text, + }).buffer; +} + +function createCloseFrame(code: number, reason: string): Uint8Array { + const reasonPayload = VSBuffer.fromString(reason); + const payload = VSBuffer.alloc(2 + reasonPayload.byteLength); + payload.writeUInt8(code >>> 8, 0); + payload.writeUInt8(code, 1); + payload.set(reasonPayload, 2); + return encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Close }).buffer; +} + +function clientFrames(stream: FakeDuplexStream): readonly IWebSocketFrame[] { + const parser = new WebSocketFrameParser(); + return stream.writes + .filter((write): write is Uint8Array => write instanceof Uint8Array) + .flatMap(write => parser.acceptChunk(VSBuffer.wrap(write))); } function concat(...chunks: Uint8Array[]): Uint8Array { @@ -165,6 +422,8 @@ function concat(...chunks: Uint8Array[]): Uint8Array { class FakeDuplexStream extends EventEmitter implements ITunnelDuplexStream { readonly writes: (Uint8Array | string)[] = []; + endCalls = 0; + destroyCalls = 0; private _ended = false; private _destroyed = false; @@ -178,6 +437,7 @@ class FakeDuplexStream extends EventEmitter implements ITunnelDuplexStream { } end(): void { + this.endCalls++; if (!this._ended) { this._ended = true; this.emit('end'); @@ -185,28 +445,28 @@ class FakeDuplexStream extends EventEmitter implements ITunnelDuplexStream { } destroy(): void { + this.destroyCalls++; if (!this._destroyed) { this._destroyed = true; this.emit('close'); } } - pause(): void { - } - - resume(): void { - } - push(chunk: Uint8Array | string): void { - this.emit('data', Buffer.from(chunk)); + this.emit('data', typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk); } } class ReentrantEndDuplexStream extends FakeDuplexStream { - endCalls = 0; - override end(): void { this.endCalls++; this.emit('end'); } } + +class ReentrantDestroyDuplexStream extends FakeDuplexStream { + override destroy(): void { + this.destroyCalls++; + this.emit('close'); + } +} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 3fdd35e316c689..5dfd6b73808e5f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -85,6 +85,7 @@ Decoupling these allows copilot sessions from different providers (local CLI, re - `vscodeAgents.sshConnect/attempt` records each complete SSH plus AHP initialization attempt from the initial connection and stored-host reconnect paths, with connect/reconnect, user-initiated, attempt number, duration, success, retry intent, and a bounded failure category. It never records host names, addresses, aliases, or raw error messages. - VS Code remote transports declare their route in AHP initialize metadata (`dev_tunnel`, `ssh`, `wsl`, `remote_extension_host`, `direct_websocket`, or `web_pub_sub`). Agent Host product telemetry combines that declaration with the host-observed physical transport and launcher kind; message telemetry retains the initiating client id and route. - `ITunnelHostService` is a required dependency of the tunnel agent host contribution on every target, because tunnel discovery filters out the locally hosted tunnel. Hosting is CLI-backed and therefore impossible in a browser, so web registers an inert implementation that reports a permanently inactive sharing state rather than leaving the service unregistered. Omitting it fails construction of the whole contribution and silently disables tunnel discovery. +- Browser tunnel connections use VS Code's shared common RFC 6455 frame codec over the Dev Tunnels duplex stream; the SDK's node-only `websocket` import is stubbed in the browser bundle. ## Stubbed Operations diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts index fc39e76bfc580e..c23ffa4a88d46f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts @@ -109,14 +109,8 @@ class BrowserTunnelRelayClient implements ITunnelRelayClient { /** Opens framed WebSockets directly over browser tunnel relay streams. */ export class BrowserTunnelSocketFactory implements ITunnelSocketFactory { - constructor( - private readonly _loadDevTunnelsWeb: () => Promise, - ) { - } - async open(stream: ITunnelDuplexStream, path: string): Promise { - const devTunnels = await this._loadDevTunnelsWeb(); - return await connectWebSocketOverDuplex(stream, { path, webSocketConnectionCtor: devTunnels.WebSocketConnection }); + return await connectWebSocketOverDuplex(stream, { path }); } } @@ -172,7 +166,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel this._loadDevTunnelsWeb = load; this._connector = options.connector ?? this._register(new TunnelAgentHostConnector( new BrowserTunnelRelayClientFactory(load), - new BrowserTunnelSocketFactory(load), + new BrowserTunnelSocketFactory(), this._logService, )); this._resolveGatewaySelection = options.resolveGatewaySelection ?? resolveGatewaySelection; @@ -354,6 +348,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel private async _getTokenForProvider(provider: 'github' | 'microsoft', silent: boolean): Promise<{ readonly token: string; readonly provider: 'github' | 'microsoft' } | undefined> { const scopes = this._productService.tunnelApplicationConfig?.authenticationProviders?.[provider]?.scopes ?? []; if (scopes.length === 0) { + this._logService.debug(`${LOG_PREFIX} No ${provider} tunnel authentication scopes are configured.`); return undefined; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts index 847d3672b3b2ec..4a11f016697332 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts @@ -7,7 +7,7 @@ import type { TunnelRelayTunnelClient } from '@microsoft/dev-tunnels-connections import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; import { AppResourcePath, FileAccess } from '../../../../../base/common/network.js'; import type { ITunnelDescriptor } from '../../../../../platform/agentHost/common/tunnelAgentHostConnector.js'; -import type { ITunnelDuplexStream, WebSocketConnectionCtor } from '../../../../../platform/agentHost/common/tunnelMessageSocket.js'; +import type { ITunnelDuplexStream } from '../../../../../platform/agentHost/common/tunnelMessageSocket.js'; const devTunnelsWebBundlePath: AppResourcePath = 'vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsModule.js'; @@ -61,7 +61,6 @@ export interface IDevTunnelsWeb { }; readonly TunnelRelayTunnelClient: new (managementClient: IDevTunnelsWebManagementClient) => IDevTunnelsWebRelayClient; readonly TunnelAccessScopes: object; - readonly WebSocketConnection: WebSocketConnectionCtor; } /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts index 5f07d5ec198aed..38c2557b07c95c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { IRemoteAgentHostLocationPreferenceService } from '../../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; import { type ITunnelConnectResult, type ITunnelGatewaySelection, type ITunnelGatewaySelectionSession, type ITunnelInfo } from '../../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { resolveGatewaySelection, type IGatewaySelectionRequest } from '../../../../../../platform/agentHost/common/tunnelGatewaySelection.js'; -import type { ITunnelDuplexStream, IWebSocketConnectionConfig, IWebSocketDuplexStream, WebSocketConnectionMessage } from '../../../../../../platform/agentHost/common/tunnelMessageSocket.js'; +import type { ITunnelDuplexStream } from '../../../../../../platform/agentHost/common/tunnelMessageSocket.js'; import type { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { BrowserTunnelRelayClientFactory, @@ -219,36 +219,11 @@ suite('BrowserTunnelAgentHostService', () => { } } - class FakeWebSocketConnection { - constructor( - _stream: IWebSocketDuplexStream, - _extensions: [], - _protocol: string | null, - _maskOutgoingPackets: boolean, - _config: IWebSocketConnectionConfig, - ) { - } - - _addSocketEventListeners(): void { } - handleSocketData(_data: Uint8Array): void { } - on(_event: 'message', _listener: (message: WebSocketConnectionMessage) => void): void; - on(_event: 'close', _listener: (code: number, reason: string) => void): void; - on(_event: 'error', _listener: (error: Error) => void): void; - on(_event: 'message' | 'close' | 'error', _listener: ((message: WebSocketConnectionMessage) => void) | ((code: number, reason: string) => void) | ((error: Error) => void)): void { } - removeListener(_event: 'message', _listener: (message: WebSocketConnectionMessage) => void): void; - removeListener(_event: 'close', _listener: (code: number, reason: string) => void): void; - removeListener(_event: 'error', _listener: (error: Error) => void): void; - removeListener(_event: 'message' | 'close' | 'error', _listener: ((message: WebSocketConnectionMessage) => void) | ((code: number, reason: string) => void) | ((error: Error) => void)): void { } - send(_data: string): void { } - close(): void { } - } - const bundle: IDevTunnelsWeb = { TunnelManagementHttpClient: FakeManagementClient, ManagementApiVersions: { Version20230927preview: {} }, TunnelRelayTunnelClient: FakeRelayClient, TunnelAccessScopes: {}, - WebSocketConnection: FakeWebSocketConnection, }; const session = await new BrowserTunnelRelayClientFactory(async () => bundle).getTunnel('tunnel-id', 'cluster-id', 'github', 'token'); await session!.createRelayClient(); From 6f10dc88ea141a835f22a0f6a9ba09a2dc2aa951 Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 16:32:47 -0700 Subject: [PATCH 03/19] Improve Agent Host debug log export (#331665) * agentHost: improve debug log export Show collection progress, include rotated VS Code logs, and use one 256 MiB artifact limit.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: harden debug log export Snapshot rotated logs, use stable paths, reject symlinks, and enforce limits across the complete ZIP.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/agent-host-logs/SKILL.md | 19 +++-- .../browser/remoteAgentHostProtocolClient.ts | 7 +- .../platform/agentHost/common/agentService.ts | 15 +--- .../agentHost/node/agentHostDebugLogs.ts | 75 +++++++++---------- .../remoteAgentHostProtocolClient.test.ts | 21 +++++- .../test/node/agentHostDebugLogs.test.ts | 64 +++++++++++----- src/vs/platform/native/common/native.ts | 7 +- .../electron-main/nativeHostMainService.ts | 39 +++++++--- .../actions/exportAgentHostDebugLogsAction.ts | 63 +++++++++++++++- .../exportAgentHostDebugLogsService.ts | 6 +- .../browser/exportAgentHostDebugLogs.test.ts | 36 ++++++++- 11 files changed, 251 insertions(+), 101 deletions(-) diff --git a/.github/skills/agent-host-logs/SKILL.md b/.github/skills/agent-host-logs/SKILL.md index a9678040a29bcd..c728c015323f0f 100644 --- a/.github/skills/agent-host-logs/SKILL.md +++ b/.github/skills/agent-host-logs/SKILL.md @@ -25,9 +25,12 @@ Files are collected best-effort, so a valid bundle may contain only some of thes events.jsonl usage.jsonl customizations.json -Agent Host.log -Window.log -Shared.log +agenthost.log +agenthost.1.log +agenthost-server.log +vscode-logs/Window/renderer.log +vscode-logs/Window/renderer.1.log +vscode-logs/Shared/sharedprocess.log ahp/*.jsonl copilot-logs/*.log remote-agenthost.log @@ -47,10 +50,10 @@ Window/client <-> AHP <-> Agent Host process <-> Copilot SDK | `usage.jsonl` | Client-captured token/credit usage, one record per model call (`turnId`, model, input/output/cache tokens, cumulative `totalNanoAiu`). The SDK's `assistant.usage` event is ephemeral and never reaches `events.jsonl`, so this is the only per-call usage record. Present only when agent-host debug logging was on. | | `customizations.json` | Snapshot of the skills/hooks/agents/MCP servers loaded for the session. The SDK's `session.*_loaded` events are ephemeral, so this is the only record of what was actually active. Present only when agent-host debug logging was on. | | `ahp/*.jsonl` | AHP traffic for a client connection. `_ahpLog.dir` is `c2s` or `s2c`; `_ahpLog.ts` is the wire timestamp. Use this to see requests, responses, subscriptions, actions, notifications, and client-visible ordering. | -| `Agent Host.log` | Local Agent Host process behavior: startup, auth, sessions, provider events, tools, Git/worktrees, and host-side errors. | +| `agenthost*.log` | Local or server Agent Host process behavior: startup, auth, sessions, provider events, tools, Git/worktrees, and host-side errors. Numbered files are older rotated segments. | | `copilot-logs/*.log` | Copilot SDK process logs that mention the selected session ID. A process log may contain other sessions too. | -| `Window.log` | Renderer/client behavior: connections, session adapters, UI state, permissions, rendering, and client-side errors. | -| `Shared.log` | Shared-process activity. Usually secondary evidence and often noisy. | +| `vscode-logs/Window/*` | Current and rotated files from the Window log group, including renderer/client behavior, network activity, views, and other window-owned logs. | +| `vscode-logs/Shared/*` | Current and rotated files from the Shared log group. Usually secondary evidence and often noisy. | | `Agent Host ().log` | Forwarded logs from a named remote Agent Host. | | `remote-agenthost.log` | A directly downloaded remote `agenthost.log`, when available. | @@ -62,9 +65,9 @@ Window/client <-> AHP <-> Agent Host process <-> Copilot SDK - Turn or provider behavior: `events.jsonl` - Token/credit usage or cost questions: `usage.jsonl` - Client/server state or ordering: `ahp/*.jsonl` - - Host implementation failure: `Agent Host.log` + - Host implementation failure: `agenthost*.log` - SDK behavior: `copilot-logs/*.log` - - UI behavior: `Window.log` + - UI behavior: `vscode-logs/Window/renderer.log` and its rotated segments 4. Search by the known time or ID, then follow the same operation into the adjacent layer. Useful correlation fields include the raw session ID, session/chat URI, `turnId`, `interactionId`, tool/request IDs, JSON-RPC request `id`, AHP `serverSeq`, and event `id`/`parentId`. diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 2e9bcb6a4aabed..acfa3007018d05 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; import { CollectAgentHostDebugLogsExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, type IAgentHostExtensionCommandMap } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; @@ -1153,9 +1153,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (resource.scheme !== Schemas.file) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent Host returned a non-file debug log resource: ${resource.toString()}`); } - const maxUncompressedSize = kind === 'archive' ? AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES : AGENT_HOST_DEBUG_LOGS_MAX_BYTES; if (!Number.isSafeInteger(result.size) || result.size < 0 || result.size > AGENT_HOST_DEBUG_LOGS_MAX_BYTES - || !Number.isSafeInteger(result.uncompressedSize) || result.uncompressedSize < 0 || result.uncompressedSize > maxUncompressedSize) { + || !Number.isSafeInteger(result.uncompressedSize) || result.uncompressedSize < 0 || result.uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned invalid debug log artifact sizes'); } if (!Array.isArray(result.entries) || result.entries.length > AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES) { @@ -1166,7 +1165,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC for (const entry of result.entries) { const segments = entry.path.split('/'); if (!entry.path || entry.path.includes('\\') || segments.some((segment: string) => !segment || segment === '.' || segment === '..') - || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES + || !Number.isSafeInteger(entry.size) || entry.size < 0 || entryPaths.has(entry.path)) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned an invalid debug log artifact manifest entry'); } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 12bcd1db4bccb9..354b0c578f30db 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -71,7 +71,7 @@ export const enum AgentHostIpcChannels { export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLoggingEnabled'; export type AgentHostDebugLogsArtifactKind = 'archive' | 'directory'; -export const AGENT_HOST_DEBUG_LOGS_MAX_BYTES = 16 * 1024 * 1024; +export const AGENT_HOST_DEBUG_LOGS_MAX_BYTES = 256 * 1024 * 1024; /** Maximum number of files in one Agent Host debug-log artifact. */ export const AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES = 1000; /** @@ -80,19 +80,6 @@ export const AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES = 1000; * never has to encode a whole archive into one JSON-RPC message. */ export const AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES = 1024 * 1024; -/** - * Upper bound on the *uncompressed* logs staged for an archive artifact. Log - * text compresses heavily, so this is deliberately far larger than - * {@link AGENT_HOST_DEBUG_LOGS_MAX_BYTES} — which still bounds the archive that - * is actually transferred. It only exists to keep zipping work finite. - */ -export const AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES = 256 * 1024 * 1024; -/** - * Upper bound on any single file inside an artifact. Oversized files are - * reduced to their trailing bytes rather than dropped, so a very large process - * log still contributes the portion that explains a recent failure. - */ -export const AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES = 10 * 1024 * 1024; export interface IAgentHostDebugLogsArtifactEntry { readonly path: string; diff --git a/src/vs/platform/agentHost/node/agentHostDebugLogs.ts b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts index 0b19b14c1cac8d..c65140993fc95f 100644 --- a/src/vs/platform/agentHost/node/agentHostDebugLogs.ts +++ b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts @@ -14,7 +14,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import type { ILogService } from '../../log/common/log.js'; import type { IAgent } from '../common/agent.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; type DebugLogsProvider = Pick; type LocalZipFile = IFile & { readonly localPath: string }; @@ -57,30 +57,18 @@ export class AgentHostDebugLogsCollector extends Disposable { providerLogsIncluded = await provider.collectDebugLogs(session, URI.file(staging)) || providerLogsIncluded; } - await this._copyOptional( - join(this._environment.logsHome.fsPath, 'agenthost.log'), - join(staging, 'agenthost.log'), - ); + await this._copyAgentHostLogs(staging); const files = await collectFiles(staging); - // Process logs can reach hundreds of megabytes. Keep the tail of any - // oversized file: it is the part that explains a recent failure, and - // it keeps the artifact within the size the client will accept — - // whether the file came from a provider's SDK bundle or was copied - // in directly. let uncompressedSize = 0; const artifactEntries: { path: string; size: number }[] = []; for (const file of files) { - const size = await truncateToTail(file.localPath, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES); + const { size } = await stat(file.localPath); uncompressedSize += size; artifactEntries.push({ path: file.path, size }); } - // A directory artifact is copied file-by-file, so its uncompressed - // size is what crosses the wire. An archive only has to keep the - // staged input bounded; the archive itself is checked after zipping. - const stagedLimit = kind === 'directory' ? AGENT_HOST_DEBUG_LOGS_MAX_BYTES : AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES; - if (uncompressedSize > stagedLimit) { - throw new Error(`Agent Host debug logs are too large (${uncompressedSize} bytes; limit ${stagedLimit} bytes)`); + if (uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { + throw new Error(`Agent Host debug logs are too large (${uncompressedSize} bytes; limit ${AGENT_HOST_DEBUG_LOGS_MAX_BYTES} bytes)`); } if (kind === 'directory') { @@ -151,11 +139,26 @@ export class AgentHostDebugLogsCollector extends Disposable { })); } - private async _copyOptional(source: string, target: string): Promise { + private async _copyAgentHostLogs(staging: string): Promise { + let names: string[]; try { - await copyFile(source, target); + names = (await readdir(this._environment.logsHome.fsPath, { withFileTypes: true })) + .filter(entry => entry.isFile() && ( + isRotatedLogFile(entry.name, 'agenthost.log') + || isRotatedLogFile(entry.name, 'agenthost-server.log') + )) + .map(entry => entry.name); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + this._logService.warn(`[AgentHostDebugLogs] Failed to enumerate Agent Host process logs`, error); + } + return; + } + for (const name of names) { + const source = join(this._environment.logsHome.fsPath, name); + try { + await copyFile(source, join(staging, name)); + } catch (error) { this._logService.warn(`[AgentHostDebugLogs] Failed to include ${source}`, error); } } @@ -187,27 +190,6 @@ function artifactKey(path: string): string { return URI.file(path).fsPath; } -/** - * Rewrites `path` in place to its last `maxBytes` bytes when it exceeds them. - * Returns the resulting size. - */ -async function truncateToTail(path: string, maxBytes: number): Promise { - const { size } = await stat(path); - if (size <= maxBytes) { - return size; - } - const handle = await open(path, 'r+'); - try { - const buffer = Buffer.allocUnsafe(maxBytes); - const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes); - await handle.write(buffer, 0, bytesRead, 0); - await handle.truncate(bytesRead); - return bytesRead; - } finally { - await handle.close(); - } -} - async function collectFiles(root: string, relative = '', files: LocalZipFile[] = []): Promise { const directory = join(root, relative); const entries = await readdir(directory, { withFileTypes: true }); @@ -224,3 +206,16 @@ async function collectFiles(root: string, relative = '', files: LocalZipFile[] = } return files; } + +function isRotatedLogFile(candidate: string, current: string): boolean { + if (candidate === current) { + return true; + } + const stem = current.endsWith('.log') ? current.slice(0, -'.log'.length) : current; + const prefix = `${stem}.`; + if (!candidate.startsWith(prefix) || !candidate.endsWith('.log')) { + return false; + } + const rotation = candidate.slice(prefix.length, -'.log'.length); + return /^[1-9]\d*$/.test(rotation); +} diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 209dae1064fa74..71c6ed558225b2 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -1436,7 +1436,7 @@ suite('RemoteAgentHostProtocolClient', () => { assert.strictEqual(await resultPromise, undefined); }); - test('collectDebugLogs accepts an archive that expands beyond the transfer limit', async () => { + test('collectDebugLogs accepts an archive with a larger uncompressed size', async () => { const { client, transport } = createClient(); const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); const entrySize = 10 * 1024 * 1024; @@ -1452,6 +1452,25 @@ suite('RemoteAgentHostProtocolClient', () => { assert.strictEqual((await resultPromise).uncompressedSize, entrySize * 2); }); + test('collectDebugLogs accepts a directory containing 30 MiB of rotated logs', async () => { + const { client, transport } = createClient(); + const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'directory'); + const entrySize = 5 * 1024 * 1024; + const entries = Array.from({ length: 6 }, (_, index) => ({ + path: index === 0 ? 'agenthost.log' : `agenthost.${index}.log`, + size: entrySize, + })); + transport.fireMessage({ + jsonrpc: '2.0', id: 1, + result: { + kind: 'directory', resource: 'file:///tmp/agent-host-debug-logs', providerLogsIncluded: true, + size: entrySize * entries.length, uncompressedSize: entrySize * entries.length, entries, + }, + }); + + assert.strictEqual((await resultPromise).uncompressedSize, 30 * 1024 * 1024); + }); + test('collectDebugLogs rejects an unsafe or inconsistent artifact manifest', async () => { const unsafe = createClient(); const unsafeResult = unsafe.client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); diff --git a/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts index be5f5ae89c32d9..ae5b866956d2ed 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts @@ -14,7 +14,7 @@ import { buffer } from '../../../../base/node/zip.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentHostDebugLogsCollector } from '../../node/agentHostDebugLogs.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES } from '../../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES } from '../../common/agentService.js'; suite('AgentHostDebugLogsCollector', () => { const emptyProvider = { id: 'test', collectDebugLogs: async () => false }; @@ -89,13 +89,10 @@ suite('AgentHostDebugLogsCollector', () => { await assert.rejects(collector.collect([{ id: 'test', collectDebugLogs: async (_session, outputDirectory) => { - // A directory artifact is copied file-by-file, so its total - // uncompressed size is what must stay bounded. No single file can - // exceed the per-file cap, so it takes several to go over. for (let i = 0; i < 3; i++) { const largeLog = join(outputDirectory.fsPath, `large-${i}.log`); await writeFile(largeLog, ''); - await truncate(largeLog, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES - 1); + await truncate(largeLog, Math.floor(AGENT_HOST_DEBUG_LOGS_MAX_BYTES / 2)); } return true; }, @@ -214,7 +211,7 @@ suite('AgentHostDebugLogsCollector', () => { }); }); - test('accepts logs that exceed the transfer limit only before compression', async () => { + test('accepts a large compressible archive', async () => { const logsHome = join(testRoot, 'logs'); const outputRoot = join(testRoot, 'tmp'); await mkdir(logsHome, { recursive: true }); @@ -227,26 +224,24 @@ suite('AgentHostDebugLogsCollector', () => { const result = await collector.collect([{ id: 'test', collectDebugLogs: async (_session, outputDirectory) => { - // Highly compressible, like real log text: together these exceed - // the transfer limit uncompressed while each stays under the - // per-file cap, yet they compress to well under the limit. + // Highly compressible, like real log text. for (let i = 0; i < 3; i++) { - await writeFile(join(outputDirectory.fsPath, `big-${i}.log`), Buffer.alloc(AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES - 1024)); + await writeFile(join(outputDirectory.fsPath, `big-${i}.log`), Buffer.alloc(8 * 1024 * 1024)); } return true; }, }], URI.parse('test:/session-1'), 'archive'); assert.deepStrictEqual({ - uncompressedOverLimit: result.uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES, - archiveUnderLimit: result.size < AGENT_HOST_DEBUG_LOGS_MAX_BYTES, + uncompressedSize: result.uncompressedSize, + archiveUnderLimit: result.size < result.uncompressedSize, }, { - uncompressedOverLimit: true, + uncompressedSize: 24 * 1024 * 1024, archiveUnderLimit: true, }); }); - test('keeps the tail of a file that exceeds the per-file cap', async () => { + test('preserves provider logs larger than 10 MiB', async () => { const logsHome = join(testRoot, 'logs'); const outputRoot = join(testRoot, 'tmp'); await mkdir(logsHome, { recursive: true }); @@ -256,7 +251,7 @@ suite('AgentHostDebugLogsCollector', () => { tmpDir: URI.file(outputRoot), }, new NullLogService())); - const head = Buffer.alloc(AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, 'A'); + const head = Buffer.alloc(12 * 1024 * 1024, 'A'); const tail = Buffer.from('THE-INTERESTING-END'); const artifact = await collector.collect([{ id: 'test', @@ -268,14 +263,49 @@ suite('AgentHostDebugLogsCollector', () => { const kept = await buffer(artifact.resource.fsPath, 'huge.log'); assert.deepStrictEqual({ - cappedToLimit: kept.length === AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, + size: kept.length, keptTheTail: kept.subarray(kept.length - tail.length).toString(), }, { - cappedToLimit: true, + size: head.length + tail.length, keptTheTail: 'THE-INTERESTING-END', }); }); + test('includes all rotated Agent Host process logs', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + await writeFile(join(logsHome, 'agenthost.log'), 'current'); + await writeFile(join(logsHome, 'agenthost.1.log'), 'previous'); + await writeFile(join(logsHome, 'agenthost.5.log'), 'oldest'); + await writeFile(join(logsHome, 'agenthost-server.log'), 'server current'); + await writeFile(join(logsHome, 'agenthost-server.1.log'), 'server previous'); + await writeFile(join(logsHome, 'agenthost.old.log'), 'not a rotated log'); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const artifact = await collector.collect([emptyProvider], URI.parse('test:/session-1'), 'archive'); + + assert.deepStrictEqual({ + paths: artifact.entries.map(entry => entry.path).sort(), + current: (await buffer(artifact.resource.fsPath, 'agenthost.log')).toString(), + previous: (await buffer(artifact.resource.fsPath, 'agenthost.1.log')).toString(), + oldest: (await buffer(artifact.resource.fsPath, 'agenthost.5.log')).toString(), + serverCurrent: (await buffer(artifact.resource.fsPath, 'agenthost-server.log')).toString(), + serverPrevious: (await buffer(artifact.resource.fsPath, 'agenthost-server.1.log')).toString(), + }, { + paths: ['agenthost-server.1.log', 'agenthost-server.log', 'agenthost.1.log', 'agenthost.5.log', 'agenthost.log'], + current: 'current', + previous: 'previous', + oldest: 'oldest', + serverCurrent: 'server current', + serverPrevious: 'server previous', + }); + }); + test('propagates provider collection failures and cleans staging', async () => { const logsHome = join(testRoot, 'logs'); const outputRoot = join(testRoot, 'tmp'); diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 440fba137a1fe4..fd5c10d5fd7551 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -41,6 +41,11 @@ export type INativeZipFile = | { readonly path: string; readonly source: URI; readonly size: number } | { readonly sourceArchive: URI }; +export interface INativeZipOptions { + readonly maxSize: number; + readonly maxEntries: number; +} + export interface IOpenAgentsWindowOptions { readonly folderUri?: UriComponents; readonly sessionResource?: UriComponents; @@ -387,7 +392,7 @@ export interface ICommonNativeHostService { * file `source` URI together with the number of leading bytes (`size`) to * stream from it. */ - createZipFile(zipPath: URI, files: INativeZipFile[]): Promise; + createZipFile(zipPath: URI, files: INativeZipFile[], options?: INativeZipOptions): Promise; // Power getSystemIdleState(idleThreshold: number): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 8a8ab3fb420832..cb912063fe68cf 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -27,7 +27,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, IOpenAgentsWindowOptions, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, INativeZipOptions, IOpenAgentsWindowOptions, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; import { IGlobalKeybindingsMainService } from '../../globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; @@ -50,13 +50,10 @@ import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES } from '../../agentHost/common/agentService.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } export const INativeHostMainService = createDecorator('nativeHostMainService'); -const MAX_MERGED_ZIP_SIZE = 16 * 1024 * 1024; - export class NativeHostMainService extends Disposable implements INativeHostMainService { declare readonly _serviceBrand: undefined; @@ -1424,7 +1421,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain //#region Zip - async createZipFile(windowId: number | undefined, zipPath: URI, files: INativeZipFile[]): Promise { + async createZipFile(windowId: number | undefined, zipPath: URI, files: INativeZipFile[], options?: INativeZipOptions): Promise { const zipFiles: IFile[] = []; const temporaryDirectories: string[] = []; try { @@ -1441,13 +1438,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain const temporaryDirectory = join(this.environmentMainService.tmpDir.fsPath, `vscode-zip-merge-${randomPath()}`); temporaryDirectories.push(temporaryDirectory); const archiveSize = (await fs.promises.stat(sourceArchive.fsPath)).size; - if (archiveSize > MAX_MERGED_ZIP_SIZE) { - throw new Error(`ZIP is too large to merge (${archiveSize} bytes; limit ${MAX_MERGED_ZIP_SIZE} bytes)`); + if (options && archiveSize > options.maxSize) { + throw new Error(`ZIP is too large to merge (${archiveSize} bytes; limit ${options.maxSize} bytes)`); + } + if (options) { + await validateZip(sourceArchive.fsPath, { + maxEntries: options.maxEntries, + maxUncompressedSize: options.maxSize, + }); } - await validateZip(sourceArchive.fsPath, { - maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, - maxUncompressedSize: AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, - }); await extract(sourceArchive.fsPath, temporaryDirectory, {}, CancellationToken.None); zipFiles.push(...await collectZipFiles(temporaryDirectory)); continue; @@ -1460,13 +1459,31 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } const paths = new Set(); + let uncompressedSize = 0; for (const file of zipFiles) { if (paths.has(file.path)) { throw new Error(`Duplicate ZIP entry '${file.path}'`); } paths.add(file.path); + if (file.contents !== undefined) { + uncompressedSize += typeof file.contents === 'string' ? Buffer.byteLength(file.contents) : file.contents.byteLength; + } else if (file.localPath) { + const size = (await fs.promises.stat(file.localPath)).size; + uncompressedSize += file.localPathSize === undefined ? size : Math.min(size, file.localPathSize); + } + if (options && uncompressedSize > options.maxSize) { + throw new Error(`ZIP expands beyond the allowed size (${uncompressedSize} bytes; limit ${options.maxSize} bytes)`); + } + } + if (options && zipFiles.length > options.maxEntries) { + throw new Error(`ZIP contains too many entries (${zipFiles.length}; limit ${options.maxEntries})`); } await zip(zipPath.fsPath, zipFiles); + const zipSize = (await fs.promises.stat(zipPath.fsPath)).size; + if (options && zipSize > options.maxSize) { + await fs.promises.rm(zipPath.fsPath, { force: true }); + throw new Error(`ZIP is too large (${zipSize} bytes; limit ${options.maxSize} bytes)`); + } } finally { await Promise.all(temporaryDirectories.map(directory => Promises.rm(directory))); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index a174dbfda94fbc..00cfe4a74a85b8 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -5,7 +5,7 @@ import { VSBuffer, newWriteableBufferStream, streamToBuffer, type VSBufferReadableStream } from '../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { joinPath } from '../../../../../base/common/resources.js'; +import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -23,9 +23,10 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { createDecorator, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; -import { IOutputService } from '../../../../services/output/common/output.js'; +import { IOutputService, isMultiSourceOutputChannelDescriptor, isSingleSourceOutputChannelDescriptor } from '../../../../services/output/common/output.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme } from '../copilotCliEventsUri.js'; @@ -213,6 +214,31 @@ export async function collectAgentHostDebugLogs( if (!channel || !descriptor) { continue; } + const sources = isSingleSourceOutputChannelDescriptor(descriptor) + ? [descriptor.source] + : isMultiSourceOutputChannelDescriptor(descriptor) ? descriptor.source : []; + const channelFolderName = channelId === WINDOW_LOG_CHANNEL_ID + ? 'Window' + : channelId === SHARED_PROCESS_LOG_CHANNEL_ID ? 'Shared' : sanitizeFilePart(descriptor.label); + const channelFolder = `vscode-logs/${channelFolderName}`; + const sourceNames = sources.map(source => basename(source.resource)); + const sourceResults = await Promise.all(sources.map(async (source, index) => { + const sourceName = sourceNames[index]; + const sourceFolder = sourceNames.filter(name => name === sourceName).length > 1 + ? `${channelFolder}/${index + 1}-${sanitizeFilePart(source.name ?? sourceName)}` + : channelFolder; + try { + const files = await collectRotatedLogFiles(sourceFolder, source.resource, fileService); + return { files, complete: files.length > 0 }; + } catch (error) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to collect rotated logs for '${source.resource.toString()}': ${error instanceof Error ? error.message : String(error)}`); + return { files: [], complete: false }; + } + })); + files.push(...sourceResults.flatMap(result => result.files)); + if (sourceResults.length > 0 && sourceResults.every(result => result.complete)) { + continue; + } const modelRef = await textModelService.createModelReference(channel.uri); try { const filename = `${descriptor.label.replace(/[/\\:*?"<>|]/g, '-')}.log`; @@ -286,9 +312,14 @@ export async function exportAgentHostDebugLogs( const chatEntitlementService = accessor.get(IChatEntitlementService); const fileService = accessor.get(IFileService); const logService = accessor.get(ILogService); + const progressService = accessor.get(IProgressService); let hostArtifact: IAgentHostDebugLogsArtifact | undefined; try { - const logs = await collectAgentHostDebugLogs(accessor, activeSession, artifact => hostArtifact = artifact); + const logs = await progressService.withProgress({ + location: ProgressLocation.Notification, + title: localize('exportDebugLogs.collectProgress', "Collecting Agent Host debug logs..."), + delay: 500, + }, () => collectAgentHostDebugLogs(accessor, activeSession, artifact => hostArtifact = artifact)); try { const saved = await exportService.save(logs.exportName, logs.files, logs.hostArtifact); if (saved) { @@ -470,6 +501,32 @@ async function createDebugLogFile(path: string, resource: URI, fileService: IFil return { path, contents: content.value.toString() }; } +export async function collectRotatedLogFiles(path: string, current: URI, fileService: IFileService): Promise { + const currentName = basename(current); + const parent = await fileService.resolve(dirname(current), { resolveMetadata: true }); + const files: IAgentHostDebugLogFile[] = []; + for (const child of parent.children ?? []) { + if (child.isFile && !child.isSymbolicLink && isRotatedLogFile(child.name, currentName)) { + const contents = await fileService.readFile(child.resource, { length: child.size }); + files.push({ path: `${path}/${child.name}`, contents: contents.value.toString() }); + } + } + return files; +} + +function isRotatedLogFile(candidate: string, current: string): boolean { + if (candidate === current) { + return true; + } + const stem = current.endsWith('.log') ? current.slice(0, -'.log'.length) : current; + const prefix = `${stem}.`; + if (!candidate.startsWith(prefix) || !candidate.endsWith('.log')) { + return false; + } + const rotation = candidate.slice(prefix.length, -'.log'.length); + return /^[1-9]\d*$/.test(rotation); +} + function toSafeRelativePathSegments(path: string): string[] { return path .replace(/\\/g, '/') diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts index 113ace11beb9c4..20d20147c8e7e4 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts @@ -9,6 +9,7 @@ import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; +import { AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES } from '../../../../../platform/agentHost/common/agentService.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -63,7 +64,10 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport await this.fileService.writeFile(localHostArchive, createHostArtifactStream(artifact, position => readChunk(artifact.resource, position))); } zipFiles.push({ sourceArchive: localHostArchive }); - await this.nativeHostService.createZipFile(saveUri, zipFiles); + await this.nativeHostService.createZipFile(saveUri, zipFiles, { + maxSize: AGENT_HOST_DEBUG_LOGS_MAX_BYTES, + maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, + }); } finally { if (temporaryHostArchive) { // Best-effort: the download may have failed before the file was diff --git a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts index b3043126bf09cd..e459b883931ae0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts @@ -5,10 +5,15 @@ import assert from 'assert'; import { VSBuffer, streamToBuffer } from '../../../../../base/common/buffer.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import type { IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; -import { createHostArtifactStream } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { collectRotatedLogFiles, createHostArtifactStream } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; function artifactOfSize(size: number): IAgentHostDebugLogsArtifact { return { @@ -59,3 +64,32 @@ suite('createHostArtifactStream', () => { await assert.rejects(streamToBuffer(stream), /empty debug log chunk/); }); }); + +suite('collectRotatedLogFiles', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('collects the current and numbered rotated logs', async () => { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + const logs = URI.from({ scheme: Schemas.inMemory, path: '/logs' }); + await fileService.createFolder(logs); + await Promise.all([ + fileService.writeFile(URI.joinPath(logs, 'renderer.log'), VSBuffer.fromString('current')), + fileService.writeFile(URI.joinPath(logs, 'renderer.1.log'), VSBuffer.fromString('previous')), + fileService.writeFile(URI.joinPath(logs, 'renderer.5.log'), VSBuffer.fromString('oldest')), + fileService.writeFile(URI.joinPath(logs, 'renderer.old.log'), VSBuffer.fromString('not rotated')), + fileService.writeFile(URI.joinPath(logs, 'network.log'), VSBuffer.fromString('different log')), + ]); + + const files = await collectRotatedLogFiles('vscode-logs/Window', URI.joinPath(logs, 'renderer.log'), fileService); + + assert.deepStrictEqual(files.map(file => ({ + path: file.path, + contents: hasKey(file, { contents: true }) ? file.contents : undefined, + })).sort((a, b) => a.path.localeCompare(b.path)), [ + { path: 'vscode-logs/Window/renderer.1.log', contents: 'previous' }, + { path: 'vscode-logs/Window/renderer.5.log', contents: 'oldest' }, + { path: 'vscode-logs/Window/renderer.log', contents: 'current' }, + ]); + }); +}); From 36533b5531ada4f2979a8889ba5baa0a441f318f Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 16:41:17 -0700 Subject: [PATCH 04/19] Tweak builtin pr skills to not ban gh cli (#331713) * Tweak builtin pr skills to not ban gh cli If the gh CLI is available and authed, some tools are not enabled in the gh mcp * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/sessions/skills/create-draft-pr/SKILL.md | 2 +- src/vs/sessions/skills/create-pr/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/skills/create-draft-pr/SKILL.md b/src/vs/sessions/skills/create-draft-pr/SKILL.md index 22f1dbbee66735..705f36e708120c 100644 --- a/src/vs/sessions/skills/create-draft-pr/SKILL.md +++ b/src/vs/sessions/skills/create-draft-pr/SKILL.md @@ -6,7 +6,7 @@ description: Create a draft pull request for the current session. Use when the u # Create Draft Pull Request -Use the GitHub MCP server to create a draft pull request — do NOT use the `gh` CLI. +| Use the GitHub MCP server to create a draft pull request if available, otherwise use `gh` CLI. 1. Run the compile and hygiene tasks (fixing any errors) 2. If there are any uncommitted changes, use the `/commit` skill to commit them diff --git a/src/vs/sessions/skills/create-pr/SKILL.md b/src/vs/sessions/skills/create-pr/SKILL.md index d9e4aebb0124fb..7a0e4caf8b74e6 100644 --- a/src/vs/sessions/skills/create-pr/SKILL.md +++ b/src/vs/sessions/skills/create-pr/SKILL.md @@ -6,7 +6,7 @@ description: Create a pull request for the current session. Use when the user wa # Create Pull Request -Use the GitHub MCP server to create a pull request — do NOT use the `gh` CLI. +Use the GitHub MCP server to create a pull request if available, otherwise use `gh` CLI. 1. Run the compile and hygiene tasks (fixing any errors) 2. If there are any uncommitted changes, use the `/commit` skill to commit them From 70373c22bdd42785c1bfa7dbc45acebcf2bc0e33 Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 16:41:28 -0700 Subject: [PATCH 05/19] agentHost: Preserve authority identity through URI serialization (#331722) * agentHost: Preserve authority identity through URI serialization Encode non-readable remote addresses with lowercase hexadecimal so connection authorities remain stable across URI serialization and cannot collide across encoding tiers.\n\nFixes #331708.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Clarify authority encoding schema Document each encoding tier and make the reserved hex prefix check explicitly case-insensitive.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/agentHostUri.ts | 22 +++++++-------- .../agentHostFileSystemProvider.test.ts | 27 ++++++++++--------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostUri.ts b/src/vs/platform/agentHost/common/agentHostUri.ts index b4683004d63c0e..225fb7e2574325 100644 --- a/src/vs/platform/agentHost/common/agentHostUri.ts +++ b/src/vs/platform/agentHost/common/agentHostUri.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { decodeBase64, encodeBase64, encodeHex, VSBuffer } from '../../../base/common/buffer.js'; import { Schemas } from '../../../base/common/network.js'; import { OperatingSystem } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; @@ -129,32 +129,28 @@ export function normalizeRemoteAgentHostAddress(address: string): string { } const REMOTE_LOCAL_AGENT_HOST_AUTHORITY = 'remote_local'; +const HEX_AGENT_HOST_AUTHORITY_PREFIX = 'hex-'; /** * Encode a remote address into an identifier that is safe for use in - * both URI schemes and URI authorities, and is collision-free. + * both URI schemes and case-insensitive URI authorities without collisions. * - * Four tiers: - * 1. The reserved ambient authority `local` is escaped for remote hosts. - * 2. Purely alphanumeric addresses are returned as-is. - * 3. "Normal" addresses containing only `[a-zA-Z0-9.:-]` get colons - * replaced with `__` (double underscore) for human readability. - * Addresses containing `_` skip this tier to keep the encoding - * collision-free (`__` can only appear from colon replacement). - * 4. Everything else is url-safe base64-encoded with a `b64-` prefix. + * The reserved `local` name becomes `remote_local`; lowercase alphanumeric + * addresses pass through; lowercase host-like addresses replace `:` with `__`; + * all other values use lowercase hex with a reserved `hex-` prefix. */ export function agentHostAuthority(address: string): string { const normalized = normalizeRemoteAgentHostAddress(address); if (normalized === 'local') { return REMOTE_LOCAL_AGENT_HOST_AUTHORITY; } - if (/^[a-zA-Z0-9]+$/.test(normalized)) { + if (/^[a-z0-9]+$/.test(normalized)) { return normalized; } - if (/^[a-zA-Z0-9.:\-]+$/.test(normalized)) { + if (/^[a-z0-9.:\-]+$/.test(normalized) && !/^hex-/i.test(normalized)) { return normalized.replaceAll(':', '__'); } - return `b64-${encodeBase64(VSBuffer.fromString(normalized), false, true)}`; + return `${HEX_AGENT_HOST_AUTHORITY_PREFIX}${encodeHex(VSBuffer.fromString(normalized))}`; } /** diff --git a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts index 74be7fa87e447f..320d48231f0301 100644 --- a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts @@ -46,7 +46,7 @@ suite('AgentHostAuthority - encoding', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('purely alphanumeric address is returned as-is', () => { + test('lowercase alphanumeric address is returned as-is', () => { assert.strictEqual(agentHostAuthority('localhost'), 'localhost'); }); @@ -57,15 +57,16 @@ suite('AgentHostAuthority - encoding', () => { assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80'); }); - test('address with underscore falls through to base64', () => { + test('address with uppercase or underscore falls through to hex', () => { + assert.strictEqual(agentHostAuthority('LOCALHOST'), 'hex-4c4f43414c484f5354'); const authority = agentHostAuthority('host_name:8080'); - assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`); + assert.ok(authority.startsWith('hex-'), `expected hex for underscore address, got: ${authority}`); }); - test('address with exotic characters is base64-encoded', () => { - assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-')); - assert.ok(agentHostAuthority('host with spaces').startsWith('b64-')); - assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-')); + test('address with exotic characters is hex-encoded', () => { + assert.ok(agentHostAuthority('user@host:8080').startsWith('hex-')); + assert.ok(agentHostAuthority('host with spaces').startsWith('hex-')); + assert.ok(agentHostAuthority('http://myhost:3000').startsWith('hex-')); }); test('ws:// prefix is normalized so authority matches bare address', () => { @@ -86,35 +87,35 @@ suite('AgentHostAuthority - encoding', () => { }, { authority: 'remote_local', normalizedAuthority: 'remote_local', - similarAddressAuthority: 'b64-cmVtb3RlX2xvY2Fs', + similarAddressAuthority: 'hex-72656d6f74655f6c6f63616c', wrappedScheme: AGENT_HOST_SCHEME, wrappedAuthority: 'remote_local', }); }); test('different addresses produce different authorities', () => { - const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080']; + const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080', '_', 'hex-5f', 'HEX-5f']; const results = cases.map(agentHostAuthority); const unique = new Set(results); assert.strictEqual(unique.size, cases.length, 'all authorities must be unique'); }); test('authority is valid in a URI authority position', () => { - const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces', '192.168.1.1:9090']; + const addresses = ['localhost', 'LOCALHOST', 'localhost:8081', 'user@host:8080', 'host with spaces', 'wss://example.com/Path', '192.168.1.1:9090']; for (const address of addresses) { const authority = agentHostAuthority(address); const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority, path: '/test' }); - assert.strictEqual(uri.authority, authority, `authority for '${address}' must round-trip through URI`); + assert.strictEqual(URI.parse(uri.toString()).authority, authority, `authority for '${address}' must round-trip through URI serialization`); } }); test('authority is valid in a URI scheme position', () => { - const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces']; + const addresses = ['localhost', 'LOCALHOST', 'localhost:8081', 'user@host:8080', 'host with spaces', 'wss://example.com/Path']; for (const address of addresses) { const authority = agentHostAuthority(address); const scheme = remoteAgentHostSessionTypeId(authority, 'copilot'); const uri = URI.from({ scheme, path: '/test' }); - assert.strictEqual(uri.scheme, scheme, `scheme for '${address}' must round-trip through URI`); + assert.strictEqual(URI.parse(uri.toString()).scheme, scheme, `scheme for '${address}' must round-trip through URI serialization`); } }); }); From 88e4c1f76c53a5a60e1dd2ce0526b3e4ef9948ce Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 16:42:01 -0700 Subject: [PATCH 06/19] sessions: avoid transferring provisional sessions (#331711) * sessions: avoid transferring provisional sessions Only materialized sessions are shared when opening an editor window, keeping provisional Agents sessions scoped to their owning composer. Fixes #331592. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: document provisional handoff gate Correct the active-session type import and explain why only materialized sessions are shared across windows. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/actions/vscodeActions.ts | 11 ++++++-- .../electron-browser/vscodeActions.test.ts | 27 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/electron-browser/actions/vscodeActions.ts b/src/vs/sessions/electron-browser/actions/vscodeActions.ts index 7d0f6f0c597732..fdf0301bbd38ed 100644 --- a/src/vs/sessions/electron-browser/actions/vscodeActions.ts +++ b/src/vs/sessions/electron-browser/actions/vscodeActions.ts @@ -32,6 +32,7 @@ import { resolveRemoteAuthority } from '../../browser/openInVSCodeUtils.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; import { IOpenedMainWindow } from '../../../platform/window/common/window.js'; import { OPEN_VSCODE_WINDOW_COMMAND_ID, RETURN_TO_VSCODE_EDITOR_COMMAND_ID, SHOULD_SHOW_RETURN_TO_VSCODE_EDITOR_COMMAND_ID } from '../../common/sessionCommands.js'; +import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; export class OpenSessionInVSCodeAction extends Action2 { static readonly ID = 'agents.openSessionInVSCode'; @@ -65,8 +66,7 @@ export class OpenSessionInVSCodeAction extends Action2 { return nativeHostService.openWindow(); } - // Hand off the active session so the opened window restores it too, not just the folder. - const chatSessionToOpen = sessionsService.activeSession.get()?.resource; + const chatSessionToOpen = getChatSessionToOpenInEditor(sessionsService.activeSession.get()); return nativeHostService.openWindow([{ folderUri }], { forceNewWindow: true, chatSessionToOpen }); } @@ -96,6 +96,13 @@ export class OpenSessionInVSCodeAction extends Action2 { } } +/** + * Provisional sessions remain owned by the Agents composer and may be replaced or disposed, so only materialized sessions are safe to share across windows. + */ +export function getChatSessionToOpenInEditor(session: IActiveSession | undefined): URI | undefined { + return session?.isCreated.get() ? session.resource : undefined; +} + export class OpenVSCodeWindowAction extends Action2 { static readonly ID = OPEN_VSCODE_WINDOW_COMMAND_ID; diff --git a/src/vs/sessions/test/electron-browser/vscodeActions.test.ts b/src/vs/sessions/test/electron-browser/vscodeActions.test.ts index 6f326482283e91..10267fdeb43349 100644 --- a/src/vs/sessions/test/electron-browser/vscodeActions.test.ts +++ b/src/vs/sessions/test/electron-browser/vscodeActions.test.ts @@ -8,7 +8,10 @@ import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; import { IOpenedMainWindow } from '../../../platform/window/common/window.js'; -import { returnToVSCodeEditor, shouldShowReturnToVSCodeEditor } from '../../electron-browser/actions/vscodeActions.js'; +import { constObservable } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { getChatSessionToOpenInEditor, returnToVSCodeEditor, shouldShowReturnToVSCodeEditor } from '../../electron-browser/actions/vscodeActions.js'; +import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; suite('VS Code Actions', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -45,6 +48,21 @@ suite('VS Code Actions', () => { assert.deepStrictEqual(calls, ['open', 'close:7']); }); + + test('only transfers materialized sessions to the editor window', () => { + const provisional = createSession('provisional', false); + const materialized = createSession('materialized', true); + + assert.deepStrictEqual({ + provisional: getChatSessionToOpenInEditor(provisional)?.toString(), + materialized: getChatSessionToOpenInEditor(materialized)?.toString(), + missing: getChatSessionToOpenInEditor(undefined), + }, { + provisional: undefined, + materialized: 'test:/materialized', + missing: undefined, + }); + }); }); function createWindow(id: number): IOpenedMainWindow { @@ -54,3 +72,10 @@ function createWindow(id: number): IOpenedMainWindow { dirty: false, }; } + +function createSession(id: string, isCreated: boolean): IActiveSession { + return new class extends mock() { + override readonly resource = URI.from({ scheme: 'test', path: `/${id}` }); + override readonly isCreated = constObservable(isCreated); + }(); +} From 8629d2d1db5083f1dcf0e61f22d7ab6804b80fcf Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:46:01 +0200 Subject: [PATCH 07/19] Enable chat input pill features by default on insiders (#331724) * Chat input pills: reusable widget, artifacts, customizations and visibility Rework the pill row above the Agents-window chat input: - Left-align the row and make it horizontally scrollable, with a reusable observable-driven ChatPillsWidget in the workbench layer. Sessions owns the adapters from session state so the workbench layer never imports sessions. - Add chat.agentSessions.showSessionMetadataInInput, which moves the session header metadata pills down into the input row, hides the header second row, moves Chats into the title toolbar and shows workspace metadata inline. - Add agent-host artifact tools (add/remove/list_artifacts) with persistence, a gating setting and an artifacts pill. GitHub pull request and issue artifacts are promoted into the session GitHub links rather than shown twice. - Derive the customizations a chat used or read from its output stream and surface them in a customizations pill that reveals the picked entry in the customizations editor. - Add a right-click visibility menu for the row, with Hide for the clicked pill and kinds grouped by whether they have data. Customizations and Subagents start hidden; Changes can never be hidden. - Consolidate pill rendering onto one base plus four implementations: icon and label, dropdown, resource label, and the animated changes pill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * also add this to the pr * Fix chat pill rendering crash from space-separated class names `classList.add` rejects tokens containing spaces, so the changes and resource pills threw while rendering. Subclasses now contribute a single modifier class instead of the full class list, which makes the mistake impossible, and the base always applies the shared classes. Port the changes pill's styling onto the shared pill classes and retire the now-dead chatTurnPills.css, whose rules all targeted the pre-refactor DOM. Cover the render path of every pill implementation, which is what CI caught and the existing unit tests missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on chat input pills - Restrict artifact `link` to http(s). A link is opened with `openExternal`, so a `file:` or custom-scheme link would reach the OS protocol handler from an agent-labelled pill. - Never let promotion lose an artifact. A GitHub reference is only removed from the artifacts pill when the GitHub pills actually surface it, so a session with no repository, or a reference belonging to another repository, keeps showing it. References from another repository are also no longer polled against the checkout's coordinates. - Let the dropdown pill's trigger close its own dropdown, and expose `aria-haspopup`/`aria-expanded` while it is summarized. - Keep the Windows drive prefix attached when matching customization paths, so `C:\repo\...` resolves. - Derive a plugin's container folder from its type rather than basename punctuation, so a versioned root such as `plugins/foo/1.2.0` no longer claims its sibling roots. - Gate customization data presence on the turn-status setting, without gating it on visibility, which would drop the pill from the menu that restores it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Show session metadata above the chat input by default on insiders `chat.agentSessions.showSessionMetadataInInput` now defaults to on for non-stable builds, matching `chat.artifactTools.enabled`, which already uses the same gate. Stable keeps the session header's metadata row. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actionWidget/browser/actionList.ts | 10 +- .../agentHost/common/agentHostSchema.ts | 13 +- .../agentHostStarter.config.contribution.ts | 11 + .../platform/agentHost/common/agentService.ts | 3 + .../agentHost/common/serverToolNames.ts | 7 + .../common/sessionArtifactCollection.ts | 148 ++++++++ .../agentHost/common/sessionArtifacts.ts | 145 ++++++++ .../platform/agentHost/node/agentService.ts | 30 +- .../agentHost/node/agentSideEffects.ts | 6 +- .../node/shared/artifactServerTools.ts | 175 ++++++++++ .../node/shared/persistSessionMetadata.ts | 1 + .../agentHost/node/shared/serverToolGroups.ts | 4 +- .../test/common/sessionArtifacts.test.ts | 105 ++++++ src/vs/sessions/AI_CUSTOMIZATIONS.md | 8 +- src/vs/sessions/LAYOUT.md | 2 + .../sessions/browser/parts/customViewNode.ts | 4 +- .../browser/parts/media/chatCompositeBar.css | 101 +++--- .../sessions/browser/parts/sessionHeader.ts | 72 ++-- .../parts/sessionHeaderMetaActionViewItem.ts | 173 --------- .../sessions/browser/sessionActionRunner.ts | 27 ++ src/vs/sessions/browser/sessionWorkspace.ts | 33 ++ src/vs/sessions/common/sessionConfig.ts | 2 + .../contrib/changes/browser/changesActions.ts | 10 +- .../contrib/chat/browser/chat.contribution.ts | 8 + .../browser/media/sessionActivityPill.css | 41 --- .../browser/media/sessionChatInputToolbar.css | 46 ++- .../chat/browser/sessionActivityPill.ts | 169 --------- .../contrib/chat/browser/sessionArtifacts.ts | 172 +++++++++ .../sessionBackgroundActivitiesControl.ts | 122 +++---- .../chat/browser/sessionBrowsersControl.ts | 144 ++++---- .../chat/browser/sessionChatInputToolbar.ts | 330 +++++++++++++----- .../browser/sessionChatInputToolbarDebug.ts | 2 +- .../chat/browser/sessionCustomizations.ts | 125 +++++++ .../chat/browser/sessionMetadataPills.ts | 79 +++++ .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../contrib/chat/common/sessionChatPills.ts | 161 +++++++++ ...sessionBackgroundActivitiesControl.test.ts | 114 ++---- .../browser/sessionBrowsersControl.test.ts | 122 +++---- .../browser/sessionChatInputToolbar.test.ts | 55 +++ .../browser/sessionCustomizations.test.ts | 49 +++ .../chat/test/common/sessionChatPills.test.ts | 103 ++++++ .../files/browser/workspaceFolderActions.ts | 46 +-- .../contrib/github/browser/issueActions.ts | 6 +- .../github/browser/pullRequestActions.ts | 6 +- .../sessions/contrib/github/common/utils.ts | 7 + .../githubReferenceActionViewItems.test.ts | 6 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 18 + .../browser/agentHostSessionArtifacts.ts | 156 +++++++++ .../browser/agentHostSessionCustomizations.ts | 329 +++++++++++++++++ .../browser/agentHostSessionFiles.ts | 54 ++- .../browser/baseAgentHostSessionsProvider.ts | 107 ++++-- .../agentHostSessionCustomizations.test.ts | 177 ++++++++++ .../localAgentHostSessionsProvider.test.ts | 102 ++++++ .../sessions/browser/sessionsActions.ts | 40 ++- .../sessions/browser/views/automationsView.ts | 8 +- .../sessions/browser/visibleSessions.ts | 2 + .../services/sessions/common/session.ts | 55 +++ .../test/browser/visibleSessions.test.ts | 23 ++ .../test/browser/sessionHeader.test.ts | 45 ++- src/vs/workbench/browser/chatChangesPill.ts | 90 +++++ src/vs/workbench/browser/chatDropdownPill.ts | 246 +++++++++++++ src/vs/workbench/browser/chatPills.ts | 288 +++++++++++++++ src/vs/workbench/browser/chatResourcePill.ts | 58 +++ src/vs/workbench/browser/media/chatPills.css | 151 ++++++++ .../aiCustomizationManagement.contribution.ts | 6 +- .../aiCustomizationManagementEditor.ts | 20 ++ .../chat/browser/media/chatTurnPills.css | 167 --------- .../media/chatThinkingContent.css | 2 +- .../chat/browser/widget/chatTurnPills.ts | 328 ++++------------- .../test/browser/widget/chatTurnPills.test.ts | 202 ++++++++++- .../sessionChatInputToolbar.fixture.ts | 105 +++++- .../sessions/sessionHeader.fixture.ts | 15 +- 72 files changed, 4381 insertions(+), 1417 deletions(-) create mode 100644 src/vs/platform/agentHost/common/sessionArtifactCollection.ts create mode 100644 src/vs/platform/agentHost/common/sessionArtifacts.ts create mode 100644 src/vs/platform/agentHost/node/shared/artifactServerTools.ts create mode 100644 src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts delete mode 100644 src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts create mode 100644 src/vs/sessions/browser/sessionActionRunner.ts create mode 100644 src/vs/sessions/browser/sessionWorkspace.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts create mode 100644 src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts create mode 100644 src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts create mode 100644 src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts create mode 100644 src/vs/sessions/contrib/chat/common/sessionChatPills.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts create mode 100644 src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts create mode 100644 src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts create mode 100644 src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionCustomizations.ts create mode 100644 src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionCustomizations.test.ts create mode 100644 src/vs/workbench/browser/chatChangesPill.ts create mode 100644 src/vs/workbench/browser/chatDropdownPill.ts create mode 100644 src/vs/workbench/browser/chatPills.ts create mode 100644 src/vs/workbench/browser/chatResourcePill.ts create mode 100644 src/vs/workbench/browser/media/chatPills.css delete mode 100644 src/vs/workbench/contrib/chat/browser/media/chatTurnPills.css diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 6be05deeb2fa43..3ac09865ca47b7 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -114,6 +114,11 @@ export interface IActionListItem { readonly keybinding?: ResolvedKeybinding; canPreview?: boolean | undefined; readonly hideIcon?: boolean; + /** + * CSS classes rendered in the item's icon slot, for icons that are not + * codicons (e.g. themed file icons). Takes precedence over `group.icon`. + */ + readonly iconClasses?: readonly string[]; readonly tooltip?: string; /** * Optional toolbar actions shown when the item is focused or hovered. @@ -292,7 +297,10 @@ class ActionItemRenderer implements IListRenderer, IAction // Clear previous element disposables data.elementDisposables.clear(); - if (element.group?.icon) { + if (element.iconClasses?.length) { + data.icon.className = ['icon', ...element.iconClasses].join(' '); + data.icon.style.color = ''; + } else if (element.group?.icon) { data.icon.className = ThemeIcon.asClassName(element.group.icon); if (element.group.icon.color) { data.icon.style.color = asCssVariable(element.group.icon.color.id); diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index c15e289671f423..10ec2d438d5495 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -500,6 +500,9 @@ export const AgentHostActiveAgentTitleGenerationConfigKey = 'activeAgentTitleGen /** Root config key controlling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledConfigKey = 'markdownPlanRichLinksEnabled'; +/** Root config key forwarded from the renderer for the artifact tools and their instruction. */ +export const AgentHostArtifactToolsConfigKey = 'artifactTools'; + // Root config key forwarded from the renderer when the `chat.agentSessions.migrateLegacyCopilotCli` // setting changes. When `true`, `listSessions` surfaces un-adopted extension-host Copilot CLI // sessions as adoptable agent-host sessions, and opening one adopts it in place. Experimental; off. @@ -795,8 +798,14 @@ export const platformRootSchema = createSchema({ }), [AgentHostMarkdownPlanRichLinksEnabledConfigKey]: schemaProperty({ type: 'boolean', - title: localize('agentHost.config.markdownPlanRichLinksEnabled.title', "Markdown Plan Rich Links"), - description: localize('agentHost.config.markdownPlanRichLinksEnabled.description', "Whether agents receive guidance for using rich links and running task markers in Markdown plan documents."), + title: localize('agentHost.config.markdownPlanRichLinks.title', "Markdown Plan Rich Links"), + description: localize('agentHost.config.markdownPlanRichLinks.description', "Whether agents receive guidance for using rich links and running task markers in Markdown plan documents."), + default: false, + }), + [AgentHostArtifactToolsConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.artifactTools.title', "Artifact Tools"), + description: localize('agentHost.config.artifactTools.description', "Whether agents can record artifacts — pull requests, issues, commits, websites, files and other resources — with the artifact tools."), default: false, }), [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: schemaProperty({ diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index e70af159e3627c..8cf3c9c134d770 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -33,10 +33,12 @@ import { AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostSystemProxyEnabledSettingId, + ArtifactToolsSettingId, } from './agentService.js'; import { AgentHostClaudeMultiRootEnabledConfigKey, AgentHostActiveAgentTitleGenerationConfigKey, + AgentHostArtifactToolsConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCodexEnabledConfigKey, @@ -181,6 +183,15 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'auto' }, agentHost: { key: AgentHostActiveAgentTitleGenerationConfigKey }, }, + [ArtifactToolsSettingId]: { + type: 'boolean', + description: nls.localize('chat.artifactTools.enabled', "When enabled, agents can record artifacts — pull requests, issues, commits, websites, files and other resources — which are surfaced above the chat input."), + default: product.quality !== 'stable', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + agentHost: { key: AgentHostArtifactToolsConfigKey }, + }, [AgentHostMarkdownPlanRichLinksEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.experimental.markdownPlanRichLinks', "When enabled, agents receive guidance for using rich links to issues, pull requests, commits, sessions, and chats, plus running task markers, when creating or editing Markdown plan documents."), diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 354b0c578f30db..3e028dea418d03 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -116,6 +116,9 @@ export const AgentHostActiveAgentTitleGenerationSettingId = 'chat.agentHost.expe /** Configuration key enabling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledSettingId = 'chat.agentHost.experimental.markdownPlanRichLinks'; +/** Configuration key gating the artifact tools and their agent instruction. */ +export const ArtifactToolsSettingId = 'chat.artifactTools.enabled'; + /** * Configuration key gating multiple-working-directory support for the Copilot * agent-host provider. When `true`, the Copilot provider advertises the diff --git a/src/vs/platform/agentHost/common/serverToolNames.ts b/src/vs/platform/agentHost/common/serverToolNames.ts index 18854e6fe2a35e..beea8e6ececd70 100644 --- a/src/vs/platform/agentHost/common/serverToolNames.ts +++ b/src/vs/platform/agentHost/common/serverToolNames.ts @@ -26,3 +26,10 @@ export const enum SessionServerToolName { GetSessionContext = 'get_session_context', DeleteSession = 'delete_session', } + +/** Names of the artifact server tools, shared between `common/` and `node/`. */ +export const enum ArtifactServerToolName { + AddArtifact = 'add_artifact', + RemoveArtifact = 'remove_artifact', + ListArtifacts = 'list_artifacts', +} diff --git a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts new file mode 100644 index 00000000000000..7cdef0d402e59d --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getSessionArtifactValue, isGitHubArtifactLink, SESSION_ARTIFACT_TYPES, SessionArtifactType, type ISessionArtifact } from './sessionArtifacts.js'; + +/** The fields an agent supplies when adding an artifact. */ +export interface ISessionArtifactInput { + readonly type: SessionArtifactType; + readonly label: string; + readonly link?: string; + readonly uri?: string; + readonly commitHash?: string; + readonly createdByThisSession?: boolean; +} + +export interface IAddSessionArtifactResult { + readonly artifacts: readonly ISessionArtifact[]; + readonly artifact: ISessionArtifact; + /** `false` when an artifact with the same value already existed. */ + readonly added: boolean; +} + +export interface IRemoveSessionArtifactResult { + readonly artifacts: readonly ISessionArtifact[]; + readonly removed: ISessionArtifact | undefined; +} + +const linkTypes: ReadonlySet = new Set([SessionArtifactType.PullRequest, SessionArtifactType.Issue, SessionArtifactType.Website, SessionArtifactType.Commit]); +const uriTypes: ReadonlySet = new Set([SessionArtifactType.File, SessionArtifactType.Resource]); +const gitHubTypes: ReadonlySet = new Set([SessionArtifactType.PullRequest, SessionArtifactType.Issue]); + +function requireString(value: unknown, field: string, toolName: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Invalid ${toolName} input: ${field} must be a non-empty string.`); + } + return value.trim(); +} + +/** + * A link is opened externally on click, which hands it to the OS protocol + * handler. Only web links may do that: a `file:` or custom-scheme link would + * otherwise let an agent-labelled pill launch a local target. + */ +function requireWebLink(value: unknown, field: string, toolName: string): string { + const link = requireString(value, field, toolName); + let scheme: string; + try { + scheme = new URL(link).protocol; + } catch { + throw new Error(`Invalid ${toolName} input: ${field} must be an absolute http(s) URL.`); + } + if (scheme !== 'http:' && scheme !== 'https:') { + throw new Error(`Invalid ${toolName} input: ${field} must be an http(s) URL, but was '${scheme}'.`); + } + return link; +} + +/** Validates and normalizes raw `add_artifact` arguments. */ +export function parseSessionArtifactInput(rawArgs: unknown, toolName: string): ISessionArtifactInput { + if (!rawArgs || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) { + throw new Error(`Invalid ${toolName} input: expected an object.`); + } + const args = rawArgs as Record; + const type = args['type']; + if (typeof type !== 'string' || !(SESSION_ARTIFACT_TYPES as readonly string[]).includes(type)) { + throw new Error(`Invalid ${toolName} input: type must be one of ${SESSION_ARTIFACT_TYPES.join(', ')}.`); + } + + const artifactType = type as SessionArtifactType; + const input: { type: SessionArtifactType; label: string; link?: string; uri?: string; commitHash?: string; createdByThisSession?: boolean } = { + type: artifactType, + label: requireString(args['label'], 'label', toolName), + }; + + if (linkTypes.has(artifactType)) { + input.link = requireWebLink(args['link'], 'link', toolName); + } + if (uriTypes.has(artifactType)) { + input.uri = requireString(args['uri'], 'uri', toolName); + } + if (artifactType === SessionArtifactType.Commit) { + input.commitHash = requireString(args['commitHash'], 'commitHash', toolName); + } + if (artifactType === SessionArtifactType.PullRequest) { + if (typeof args['createdByThisSession'] !== 'boolean') { + throw new Error(`Invalid ${toolName} input: createdByThisSession must be a boolean for pull request artifacts.`); + } + input.createdByThisSession = args['createdByThisSession']; + } + return input; +} + +/** + * The artifacts recorded on a session. Immutable: mutations return the next + * list so callers stay in control of persisting and publishing it. + */ +export class SessionArtifactCollection { + + constructor(private readonly _artifacts: readonly ISessionArtifact[] = []) { } + + get artifacts(): readonly ISessionArtifact[] { + return this._artifacts; + } + + /** + * Adds an artifact unless one with the same value already exists, in which + * case the existing artifact is returned unchanged. + */ + add(input: ISessionArtifactInput, createId: () => string): IAddSessionArtifactResult { + const artifact = this._create(input, createId); + const value = getSessionArtifactValue(artifact); + const existing = this._artifacts.find(candidate => getSessionArtifactValue(candidate) === value); + if (existing) { + return { artifacts: this._artifacts, artifact: existing, added: false }; + } + return { artifacts: [...this._artifacts, artifact], artifact, added: true }; + } + + remove(id: string): IRemoveSessionArtifactResult { + const removed = this._artifacts.find(artifact => artifact.id === id); + return { + artifacts: removed ? this._artifacts.filter(artifact => artifact !== removed) : this._artifacts, + removed, + }; + } + + private _create(input: ISessionArtifactInput, createId: () => string): ISessionArtifact { + const artifact: { + id: string; + type: SessionArtifactType; + label: string; + link?: string; + uri?: string; + commitHash?: string; + isGitHub?: boolean; + createdByThisSession?: boolean; + } = { id: createId(), type: input.type, label: input.label }; + + if (input.link !== undefined) { artifact.link = input.link; } + if (input.uri !== undefined) { artifact.uri = input.uri; } + if (input.commitHash !== undefined) { artifact.commitHash = input.commitHash; } + if (input.link !== undefined && gitHubTypes.has(input.type)) { artifact.isGitHub = isGitHubArtifactLink(input.link); } + if (input.createdByThisSession !== undefined) { artifact.createdByThisSession = input.createdByThisSession; } + return artifact; + } +} diff --git a/src/vs/platform/agentHost/common/sessionArtifacts.ts b/src/vs/platform/agentHost/common/sessionArtifacts.ts new file mode 100644 index 00000000000000..41e0d7ca599023 --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionArtifacts.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SessionSummaryMeta } from './state/sessionState.js'; + +/** + * Artifact kinds an agent can record on its session. Each kind carries the one + * field the client needs to open it, plus a label. + */ +export const enum SessionArtifactType { + PullRequest = 'pullRequest', + Issue = 'issue', + Commit = 'commit', + Website = 'website', + File = 'file', + Resource = 'resource', +} + +export const SESSION_ARTIFACT_TYPES: readonly SessionArtifactType[] = [ + SessionArtifactType.PullRequest, + SessionArtifactType.Issue, + SessionArtifactType.Commit, + SessionArtifactType.Website, + SessionArtifactType.File, + SessionArtifactType.Resource, +]; + +/** A session artifact as stored by the host and published to clients. */ +export interface ISessionArtifact { + readonly id: string; + readonly type: SessionArtifactType; + readonly label: string; + /** Link for pull request, issue, commit and website artifacts. */ + readonly link?: string; + /** Resource URI for file and resource artifacts. */ + readonly uri?: string; + /** Commit hash for commit artifacts. */ + readonly commitHash?: string; + /** Whether a pull request or issue link points at GitHub. Host-computed. */ + readonly isGitHub?: boolean; + /** Whether this session created the pull request, rather than only referencing it. */ + readonly createdByThisSession?: boolean; +} + +/** + * Reserved key under {@link SessionSummaryMeta} holding the session's agent-set + * artifacts. VS Code convention layered on the protocol's generic `_meta` bag. + */ +export const SESSION_META_ARTIFACTS_KEY = 'agentHost/sessionArtifacts'; + +function isSessionArtifactType(value: unknown): value is SessionArtifactType { + return typeof value === 'string' && (SESSION_ARTIFACT_TYPES as readonly string[]).includes(value); +} + +function parseSessionArtifact(value: unknown): ISessionArtifact | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + if (typeof raw['id'] !== 'string' || typeof raw['label'] !== 'string' || !isSessionArtifactType(raw['type'])) { + return undefined; + } + const artifact: { + id: string; + type: SessionArtifactType; + label: string; + link?: string; + uri?: string; + commitHash?: string; + isGitHub?: boolean; + createdByThisSession?: boolean; + } = { id: raw['id'], type: raw['type'], label: raw['label'] }; + + if (typeof raw['link'] === 'string') { artifact.link = raw['link']; } + if (typeof raw['uri'] === 'string') { artifact.uri = raw['uri']; } + if (typeof raw['commitHash'] === 'string') { artifact.commitHash = raw['commitHash']; } + if (typeof raw['isGitHub'] === 'boolean') { artifact.isGitHub = raw['isGitHub']; } + if (typeof raw['createdByThisSession'] === 'boolean') { artifact.createdByThisSession = raw['createdByThisSession']; } + return artifact; +} + +/** Reads the artifacts recorded on a session's `_meta` bag. */ +export function readSessionArtifacts(meta: SessionSummaryMeta | undefined): readonly ISessionArtifact[] { + const value = meta?.[SESSION_META_ARTIFACTS_KEY]; + if (!Array.isArray(value)) { + return []; + } + const artifacts: ISessionArtifact[] = []; + for (const entry of value) { + const artifact = parseSessionArtifact(entry); + if (artifact) { + artifacts.push(artifact); + } + } + return artifacts; +} + +/** Returns `meta` with the artifact slot replaced, dropping it when empty. */ +export function withSessionArtifacts(meta: SessionSummaryMeta | undefined, artifacts: readonly ISessionArtifact[]): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (artifacts.length > 0) { + next[SESSION_META_ARTIFACTS_KEY] = artifacts; + } else { + delete next[SESSION_META_ARTIFACTS_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +/** Serializes artifacts for the session database. */ +export function stringifySessionArtifacts(artifacts: readonly ISessionArtifact[]): string { + return JSON.stringify(artifacts); +} + +/** Parses artifacts previously written by {@link stringifySessionArtifacts}. */ +export function parseSessionArtifacts(value: string | undefined): readonly ISessionArtifact[] { + if (!value) { + return []; + } + try { + return readSessionArtifacts({ [SESSION_META_ARTIFACTS_KEY]: JSON.parse(value) }); + } catch { + return []; + } +} + +/** + * The value that identifies an artifact for de-duplication: its link, resource + * URI or commit hash, normalized for comparison. + */ +export function getSessionArtifactValue(artifact: ISessionArtifact): string { + const value = artifact.link ?? artifact.uri ?? artifact.commitHash ?? ''; + return value.trim().toLowerCase(); +} + +/** Whether a pull request or issue link points at github.com or a GitHub Enterprise host. */ +export function isGitHubArtifactLink(link: string): boolean { + try { + const { hostname } = new URL(link); + return hostname === 'github.com' || hostname === 'www.github.com' || hostname.endsWith('.github.com') || hostname.startsWith('github.'); + } catch { + return false; + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 136ea0635a64b3..862f07a1e76299 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -63,7 +63,9 @@ import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadataValues, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; +import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; import { buildWorktreeFailureNotification, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; @@ -95,7 +97,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService, type IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; import { SessionCoordinationService } from './sessionCoordination.js'; @@ -838,7 +840,7 @@ export class AgentService extends Disposable implements IAgentService { () => this._agentMergeController.isEnabled(), session => this._agentMergeController.getTurnContext(session), ); - this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor(), agentMergeTools)); + this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor(), agentMergeTools, this._createArtifactServerToolAccessor())); } /** @@ -1151,6 +1153,18 @@ export class AgentService extends Disposable implements IAgentService { return this._configurationService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true; } + /** Dependency surface for the artifact server-tool group. */ + private _createArtifactServerToolAccessor(): IArtifactServerToolAccessor { + return { + isEnabled: () => this._isArtifactToolsEnabled(), + persist: (session, artifacts) => persistSessionMetadata(this._sessionDataService, this._logService, session, SESSION_ARTIFACTS_KEY, stringifySessionArtifacts(artifacts)), + }; + } + + private _isArtifactToolsEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) === true; + } + private _getServerToolCreationDefaults(source: URI): ISessionCreationDefaults | undefined { const session = this._stateManager.getSessionState(source.toString()); if (!session) { @@ -1864,8 +1878,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); // This session is an internal peer-chat backing (e.g. a // Claude peer chat's SDK session, enumerated by the agent's @@ -1923,6 +1937,10 @@ export class AgentService extends Disposable implements IAgentService { if (multiRoot) { updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; } + const artifacts = parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY]); + if (artifacts.length > 0) { + updated = { ...updated, _meta: withSessionArtifacts(updated._meta, artifacts) }; + } const folderPickerDecision = parseSessionFolderPickerDecision(m[SESSION_META_FOLDER_PICKER_KEY]); if (folderPickerDecision) { updated = { ...updated, _meta: withSessionFolderPickerDecision(updated._meta, folderPickerDecision) }; @@ -4831,6 +4849,7 @@ export class AgentService extends Disposable implements IAgentService { [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, + [SESSION_ARTIFACTS_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, @@ -4894,6 +4913,7 @@ export class AgentService extends Disposable implements IAgentService { sessionMetadata = withSessionOrchestration(sessionMetadata, orchestration); } sessionMetadata = withSessionMultiRootMetadata(sessionMetadata, parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY])); + sessionMetadata = withSessionArtifacts(sessionMetadata, parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY])); sessionMetadata = withSessionFolderPickerDecision(sessionMetadata, parseSessionFolderPickerDecision(m[SESSION_META_FOLDER_PICKER_KEY])); if (m.configValues) { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 9c04ed0dc49a3e..5b6fa6bb4d09b3 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -17,7 +17,8 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, platformRootSchema, type SessionMode } from '../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, platformRootSchema, type SessionMode } from '../common/agentHostSchema.js'; +import { ARTIFACT_TOOLS_INSTRUCTION } from './shared/artifactServerTools.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; @@ -2202,6 +2203,9 @@ export class AgentSideEffects extends Disposable { ...(this._agentConfigService.getRootValue(platformRootSchema, AgentHostMarkdownPlanRichLinksEnabledConfigKey) ? [createMarkdownPlanRichLinksInstruction(chat)] : []), + ...(this._agentConfigService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) + ? [ARTIFACT_TOOLS_INSTRUCTION] + : []), ...(terminalSurface ? [createTerminalChatInstruction(terminalSurface)] : []), ...(renameInstruction ? [renameInstruction] : []), ]; diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts new file mode 100644 index 00000000000000..ce14e16bf967ff --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ArtifactServerToolName } from '../../common/serverToolNames.js'; +import { parseSessionArtifactInput, SessionArtifactCollection } from '../../common/sessionArtifactCollection.js'; +import { readSessionArtifacts, SESSION_ARTIFACT_TYPES, SessionArtifactType, withSessionArtifacts, type ISessionArtifact } from '../../common/sessionArtifacts.js'; +import { parseRequiredSessionUriFromChatUri, type ToolDefinition } from '../../common/state/sessionState.js'; +import type { AgentHostStateManager } from '../agentHostStateManager.js'; +import type { IServerToolDisplay, IServerToolExecutionContext, IServerToolGroup } from './agentServerToolHost.js'; + +const addArtifactInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + type: { + type: 'string', + enum: [...SESSION_ARTIFACT_TYPES], + description: 'The kind of artifact. Use `resource` only when no other kind applies.', + }, + label: { type: 'string', description: 'Short label shown to the user.' }, + link: { type: 'string', description: 'URL of the pull request, issue, commit or website. Required for those kinds.' }, + uri: { type: 'string', description: 'URI of the file or resource. Required for the `file` and `resource` kinds.' }, + commitHash: { type: 'string', description: 'The commit hash. Required for the `commit` kind.' }, + createdByThisSession: { type: 'boolean', description: 'Required for the `pullRequest` kind: `true` when this session created the pull request, `false` when it only references an existing one.' }, + }, + required: ['type', 'label'], +}; + +const removeArtifactInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + id: { type: 'string', description: 'The artifact id returned by `add_artifact` or `list_artifacts`.' }, + }, + required: ['id'], +}; + +const listArtifactsInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: {}, +}; + +export const artifactServerToolDefinitions: ToolDefinition[] = [ + { + name: ArtifactServerToolName.AddArtifact, + title: 'Add Artifact', + description: 'Record something the user will want to open — a pull request, issue, notable commit, website, file or other resource — so it is surfaced next to the chat input.', + inputSchema: addArtifactInputSchema, + annotations: { readOnlyHint: false }, + }, + { + name: ArtifactServerToolName.RemoveArtifact, + title: 'Remove Artifact', + description: 'Remove an artifact from this session by id.', + inputSchema: removeArtifactInputSchema, + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + { + name: ArtifactServerToolName.ListArtifacts, + title: 'List Artifacts', + description: 'List the artifacts recorded on this session, with their ids.', + inputSchema: listArtifactsInputSchema, + annotations: { readOnlyHint: true }, + }, +]; + +/** Host services the artifact tools need beyond the session state. */ +export interface IArtifactServerToolAccessor { + /** Whether the artifact tools are advertised and executable. */ + readonly isEnabled: () => boolean; + /** Persists a session's artifacts so they survive a host restart. */ + readonly persist: (session: string, artifacts: readonly ISessionArtifact[]) => void; +} + +function describeArtifact(artifact: ISessionArtifact): string { + const value = artifact.link ?? artifact.uri ?? artifact.commitHash ?? ''; + return `${artifact.id} (${artifact.type}) ${artifact.label}${value ? ` — ${value}` : ''}`; +} + +/** + * Reads, mutates and republishes the artifacts of the session that owns the + * executing chat. The artifacts live on the session's `_meta` bag, so a change + * reaches subscribed clients through the regular action envelope. + */ +class SessionArtifacts { + + private readonly _session: string; + + constructor( + private readonly _stateManager: AgentHostStateManager, + context: IServerToolExecutionContext, + ) { + this._session = parseRequiredSessionUriFromChatUri(context.chatUri); + } + + read(): SessionArtifactCollection { + return new SessionArtifactCollection(readSessionArtifacts(this._stateManager.getSessionState(this._session)?._meta)); + } + + write(artifacts: readonly ISessionArtifact[], accessor: IArtifactServerToolAccessor): void { + const meta = this._stateManager.getSessionState(this._session)?._meta; + this._stateManager.setSessionMeta(this._session, withSessionArtifacts(meta, artifacts)); + accessor.persist(this._session, artifacts); + } +} + +export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAccessor): IServerToolGroup { + return { + definitions: artifactServerToolDefinitions, + isEnabled(): boolean { + return accessor?.isEnabled() === true; + }, + getDisplay(toolName, args): IServerToolDisplay | undefined { + switch (toolName) { + case ArtifactServerToolName.AddArtifact: { + const label = (args as { label?: unknown } | undefined)?.label; + return typeof label === 'string' && label.length > 0 + ? { displayName: 'Add Artifact', invocationMessage: `Add artifact "${label}"`, pastTenseMessage: `Added artifact "${label}"` } + : { displayName: 'Add Artifact', invocationMessage: 'Add artifact', pastTenseMessage: 'Added artifact' }; + } + case ArtifactServerToolName.RemoveArtifact: + return { displayName: 'Remove Artifact', invocationMessage: 'Remove artifact', pastTenseMessage: 'Removed artifact' }; + case ArtifactServerToolName.ListArtifacts: + return { displayName: 'List Artifacts', invocationMessage: 'List artifacts', pastTenseMessage: 'Listed artifacts' }; + default: + return undefined; + } + }, + execute(stateManager, context, toolName, rawArgs): string { + if (!accessor) { + throw new Error(`${toolName} is unavailable in this host.`); + } + + const artifacts = new SessionArtifacts(stateManager, context); + switch (toolName) { + case ArtifactServerToolName.AddArtifact: { + const input = parseSessionArtifactInput(rawArgs, ArtifactServerToolName.AddArtifact); + const result = artifacts.read().add(input, generateUuid); + if (!result.added) { + return `Artifact already recorded: ${describeArtifact(result.artifact)}`; + } + artifacts.write(result.artifacts, accessor); + return `Added artifact: ${describeArtifact(result.artifact)}`; + } + case ArtifactServerToolName.RemoveArtifact: { + const id = (rawArgs as { id?: unknown } | undefined)?.id; + if (typeof id !== 'string' || id.length === 0) { + throw new Error(`Invalid ${ArtifactServerToolName.RemoveArtifact} input: id must be a non-empty string.`); + } + const result = artifacts.read().remove(id); + if (!result.removed) { + return `No artifact with id ${id}.`; + } + artifacts.write(result.artifacts, accessor); + return `Removed artifact: ${describeArtifact(result.removed)}`; + } + case ArtifactServerToolName.ListArtifacts: { + const current = artifacts.read().artifacts; + return current.length === 0 + ? 'No artifacts recorded for this session.' + : current.map(describeArtifact).join('\n'); + } + default: + throw new Error(`Unknown artifact tool: ${toolName}`); + } + }, + }; +} + +/** + * The instruction appended to every agent's host instructions while the + * artifact tools are enabled. + */ +export const ARTIFACT_TOOLS_INSTRUCTION = `When you produce something the user will want to open — a pull request, an issue, a notable commit, a website, a plan file or another resource — record it once with \`${ArtifactServerToolName.AddArtifact}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits). Do not record routine files you merely edited, and do not record every commit you make — record a commit only when the user asked you to commit, or when you found a commit worth showing them, for example while investigating.`; diff --git a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts index eed868163d8df4..0bb37e2197417a 100644 --- a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts +++ b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts @@ -9,6 +9,7 @@ import type { ISessionDataService } from '../../common/sessionDataService.js'; export const SESSION_CUSTOM_TITLE_KEY = 'customTitle'; export const SESSION_CUSTOM_TITLE_SOURCE_KEY = 'customTitleSource'; +export const SESSION_ARTIFACTS_KEY = 'sessionArtifacts'; export const AGENT_HOST_TITLE_SOURCE_USER = 'user'; export const AGENT_HOST_TITLE_SOURCE_AGENT = 'agent'; export const AGENT_HOST_TITLE_SOURCE_AUTO = 'auto'; diff --git a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts index 207bfa10a5a265..706f9a3a44dbcf 100644 --- a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts +++ b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts @@ -7,6 +7,7 @@ import { feedbackServerToolGroup } from './agentFeedbackServerTools.js'; import { createSessionServerToolGroup, type ISessionServerToolAccessor } from './sessionServerTools.js'; import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; import { createAgentMergeServerToolGroup, type IAgentMergeToolAccessor } from './agentMergeServerTools.js'; +import { createArtifactServerToolGroup, type IArtifactServerToolAccessor } from './artifactServerTools.js'; /** * Builds the server-tool groups contributed to every agent host session, in @@ -24,11 +25,12 @@ import { createAgentMergeServerToolGroup, type IAgentMergeToolAccessor } from '. * When omitted (the pure display path) the session group's `execute` is inert, * but its definitions and display remain available. */ -export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor): readonly IServerToolGroup[] { +export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor, artifactAccessor?: IArtifactServerToolAccessor): readonly IServerToolGroup[] { return [ feedbackServerToolGroup, createSessionServerToolGroup(sessionAccessor), createAgentMergeServerToolGroup(agentMergeAccessor), + createArtifactServerToolGroup(artifactAccessor), ]; } diff --git a/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts new file mode 100644 index 00000000000000..192d86b305bf2a --- /dev/null +++ b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { parseSessionArtifactInput, SessionArtifactCollection } from '../../common/sessionArtifactCollection.js'; +import { isGitHubArtifactLink, parseSessionArtifacts, readSessionArtifacts, SessionArtifactType, stringifySessionArtifacts, withSessionArtifacts } from '../../common/sessionArtifacts.js'; + +suite('Session Artifacts', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let nextId = 0; + const createId = () => `id-${++nextId}`; + + setup(() => { nextId = 0; }); + + test('adds typed artifacts and stamps isGitHub for pull requests and issues', () => { + const collection = new SessionArtifactCollection(); + const pullRequest = collection.add(parseSessionArtifactInput({ type: 'pullRequest', label: 'Fix login', link: 'https://github.com/microsoft/vscode/pull/1', createdByThisSession: true }, 'add_artifact'), createId); + const issue = new SessionArtifactCollection(pullRequest.artifacts).add(parseSessionArtifactInput({ type: 'issue', label: 'Crash', link: 'https://example.com/issues/2' }, 'add_artifact'), createId); + const commit = new SessionArtifactCollection(issue.artifacts).add(parseSessionArtifactInput({ type: 'commit', label: 'Refactor', link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123' }, 'add_artifact'), createId); + + assert.deepStrictEqual(commit.artifacts, [ + { id: 'id-1', type: SessionArtifactType.PullRequest, label: 'Fix login', link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true, createdByThisSession: true }, + { id: 'id-2', type: SessionArtifactType.Issue, label: 'Crash', link: 'https://example.com/issues/2', isGitHub: false }, + { id: 'id-3', type: SessionArtifactType.Commit, label: 'Refactor', link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123' }, + ]); + }); + + test('rejects a duplicate value and returns the existing artifact', () => { + const first = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'file', label: 'Plan', uri: 'file:///repo/plan.md' }, 'add_artifact'), createId); + const duplicate = new SessionArtifactCollection(first.artifacts).add(parseSessionArtifactInput({ type: 'file', label: 'Plan again', uri: 'file:///repo/plan.md' }, 'add_artifact'), createId); + + assert.deepStrictEqual({ + added: duplicate.added, + id: duplicate.artifact.id, + count: duplicate.artifacts.length, + }, { + added: false, + id: 'id-1', + count: 1, + }); + }); + + test('removes by id and reports unknown ids', () => { + const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com' }, 'add_artifact'), createId); + const collection = new SessionArtifactCollection(added.artifacts); + + assert.deepStrictEqual({ + removed: collection.remove('id-1').artifacts.length, + unknown: collection.remove('missing').removed, + }, { + removed: 0, + unknown: undefined, + }); + }); + + test('validates required fields per type', () => { + assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No link' }, 'add_artifact'), /link/); + assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No flag', link: 'https://github.com/microsoft/vscode/pull/1' }, 'add_artifact'), /createdByThisSession/); + assert.throws(() => parseSessionArtifactInput({ type: 'file', label: 'No uri' }, 'add_artifact'), /uri/); + assert.throws(() => parseSessionArtifactInput({ type: 'commit', label: 'No hash', link: 'https://example.com' }, 'add_artifact'), /commitHash/); + assert.throws(() => parseSessionArtifactInput({ type: 'unknown', label: 'Bad' }, 'add_artifact'), /type/); + }); + + test('rejects links that are not http(s), since a link is opened externally', () => { + const parse = (link: string) => () => parseSessionArtifactInput({ type: 'website', label: 'Link', link }, 'add_artifact'); + + assert.throws(parse('file:///etc/passwd'), /http\(s\)/); + assert.throws(parse('vscode://extension/evil'), /http\(s\)/); + assert.throws(parse('javascript:alert(1)'), /http\(s\)/); + assert.throws(parse('/not/absolute'), /absolute http\(s\) URL/); + assert.strictEqual(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com/x' }, 'add_artifact').link, 'https://example.com/x'); + }); + + test('round-trips artifacts through the meta bag and the session database', () => { + const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'resource', label: 'Dashboard', uri: 'https://example.com/dash' }, 'add_artifact'), createId); + const meta = withSessionArtifacts({ other: 'kept' }, added.artifacts); + + assert.deepStrictEqual({ + meta, + fromMeta: readSessionArtifacts(meta), + fromStorage: parseSessionArtifacts(stringifySessionArtifacts(added.artifacts)), + cleared: withSessionArtifacts(meta, []), + corrupted: parseSessionArtifacts('not json'), + }, { + meta: { other: 'kept', 'agentHost/sessionArtifacts': added.artifacts }, + fromMeta: added.artifacts, + fromStorage: added.artifacts, + cleared: { other: 'kept' }, + corrupted: [], + }); + }); + + test('detects GitHub links', () => { + assert.deepStrictEqual([ + isGitHubArtifactLink('https://github.com/microsoft/vscode/pull/1'), + isGitHubArtifactLink('https://github.contoso.com/org/repo/issues/2'), + isGitHubArtifactLink('https://gitlab.com/org/repo/-/merge_requests/3'), + isGitHubArtifactLink('not a url'), + ], [true, true, false, false]); + }); +}); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index f027fec91d7d51..52656a39c4653b 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -381,9 +381,15 @@ All commands and UI respect `ChatContextKeys.enabled`. | Command ID | Purpose | |-----------|---------| -| `aiCustomization.openManagementEditor` | Opens the management editor, optionally accepting an `AICustomizationManagementSection` to deep-link | +| `aiCustomization.openManagementEditor` | Opens the management editor, optionally accepting an `AICustomizationManagementSection` to deep-link, or an object with `section`, `sessionType`, and `revealUri` | | `aiCustomization.openMarketplace` | Opens the management editor with marketplace browse mode active. Accepts an optional section (`mcpServers` or `plugins`); defaults to `mcpServers` | +### Revealing a Specific Customization + +`aiCustomization.openManagementEditor` accepts a `revealUri` alongside `section`, which selects that section and then reveals and selects the row backed by the URI (`AICustomizationManagementEditor.revealCustomizationByUri`). The reveal retries while the list loads, and clears the search box once so a filtered list cannot hide the target. Only prompt-backed sections have URI-addressable rows; for MCP servers and plugins, selecting the section is the whole reveal. + +The customizations pill above the Agents-window chat input is the main consumer: it lists the customizations a chat used or read and reveals the one the user picks. + ## Settings User-facing settings use the `chat.customizations.` namespace. Currently, no settings are exposed for the management editor. diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index a7d71b90df2a0d..74a03439e313bf 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -206,6 +206,8 @@ A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/se The header is centered and capped to 990px via its own CSS class (`.chat-composite-bar.session-header-bar` in [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)); `SessionView` measures the header's reported height and lays the chat groups grid out below it. The chat groups grid is laid out at full session width so each group's scrollable viewport (and scrollbar) stays flush to the far-right edge; only the inner chat content (message/input cards, via `.interactive-item-container`, capped to 950px in [browser/media/style.css](src/vs/sessions/browser/media/style.css)) is width-constrained and centered via CSS. The scroll-to-bottom button follows the trailing edge of this centered content column rather than the full-width viewport edge. Each constrained message row is also the positioning context for request overlays such as steering-message actions, keeping those controls anchored to the message instead of the full-width scroll viewport. +Session metadata defaults to a second header row containing workspace, aggregate changes, pull requests, issues, and Chats. When `chat.agentSessions.showSessionMetadataInInput` is enabled, that row is removed: aggregate changes, pull requests, and issues join the horizontally scrollable pill row above the input; Chats moves into the title toolbar with its existing visibility rules; and read-only workspace metadata appears inline after the session title. Last-turn status pills remain available after the turn completes in this placement. The artifacts pill merges the artifacts the agent recorded with the previewable files the session wrote outside its workspace, de-duplicated by resource with the agent's entries winning; a single artifact opens directly, while several collapse into an `N Artifacts` pill whose dropdown groups them by type. Right-clicking the row — on a pill or the empty space beside it — offers the pill visibility menu: `Hide ` for the pill under the cursor, then the kinds the session has data for, then the kinds it does not, separated into those three groups. Changes is never listed because it always shows once it has data. Customizations and Subagents start hidden and are turned on from this menu; choices persist across windows. The pills opt into `allowContextMenu` so the toolbar does not swallow the right-click per item. The customizations pill is chat-scoped rather than session-scoped and always summarizes — one customization still reads `1 Customization` — with a dropdown grouped by customization type that reveals the picked entry in the customizations editor. The shared `ChatPillsWidget` lives in the workbench layer and consumes observable pill descriptors; the artifacts and customizations pills share one `ChatSectionPillActionViewItem` configured by presentation options. Sessions owns the adapters from session state and menus so the workbench layer never imports Sessions. + **Composer clipping.** Monaco measures its host from `clientWidth`, which includes padding. The new-session editor therefore expresses its horizontal inset with margin so its scrollable element remains inside the clipped input surface; the running-session editor's rounded working-state clip extends through the input's trailing padding so the full scrollbar remains visible. **Pitfall:** absolute request overlays must not remain positioned against the full-width `.interactive-session` after message rows are independently constrained. Make the constrained row their positioning context or hover actions drift into the viewport gutter. Request rows must also override the tree's `.monaco-tl-contents { overflow: hidden; }`, otherwise controls positioned above the request are clipped at the row boundary. diff --git a/src/vs/sessions/browser/parts/customViewNode.ts b/src/vs/sessions/browser/parts/customViewNode.ts index 91c58b4739c41e..4a34e358636f16 100644 --- a/src/vs/sessions/browser/parts/customViewNode.ts +++ b/src/vs/sessions/browser/parts/customViewNode.ts @@ -16,7 +16,7 @@ import { asCssVariable } from '../../../platform/theme/common/colorUtils.js'; import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; import { activeSessionViewBackground, activeSessionViewForeground } from '../../common/theme.js'; import { AbstractCustomView, ICustomViewDescriptor } from '../../services/customView/browser/customView.js'; -import { SessionHeaderMetaActionViewItem } from './sessionHeaderMetaActionViewItem.js'; +import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js'; /** * A leaf of the custom view grid. Owns the shared chrome — a header with the @@ -81,7 +81,7 @@ export class CustomViewNode extends Disposable { toolbarOptions: { primaryGroup: () => true }, actionViewItemProvider: buttonBar ? (action, options) => action instanceof MenuItemAction - ? instantiationService.createInstance(SessionHeaderMetaActionViewItem, undefined, action, options) + ? instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options) : undefined : undefined, })); diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 655338e9ba042c..93943c6187a032 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -100,6 +100,45 @@ white-space: nowrap; } +.chat-composite-bar-workspace-meta { + display: inline-flex; + align-items: center; + gap: var(--vscode-spacing-size40); + flex: 0 1 auto; + min-width: 0; + max-width: 40%; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-regular); + white-space: nowrap; +} + +.chat-composite-bar-workspace-meta.hidden { + display: none; +} + +/* Compact glyph at the compact size. The compound selector outranks the base + `.codicon` font shorthand; the clamped box keeps combined glyphs (wider + advance) tight against the label, and the padding optically centers it. */ +.monaco-workbench .chat-composite-bar-workspace-meta-icon.codicon[class*='codicon-'] { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + margin: 0; + padding: 3px 1px 0 2px; + font-size: var(--vscode-codiconFontSize-compact); + flex-shrink: 0; +} + +.chat-composite-bar-workspace-meta-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Hover feedback: only when the title can actually be renamed and we aren't currently editing it. */ .chat-composite-bar-session-title.editable { @@ -167,7 +206,7 @@ white-space: nowrap; } -/* Session header meta toolbar: contributed actions render as compact secondary buttons. */ +/* Session header meta toolbar */ .chat-composite-bar-meta-toolbar, .chat-composite-bar-meta-toolbar .monaco-action-bar, .chat-composite-bar-meta-toolbar .actions-container { @@ -183,66 +222,6 @@ gap: 6px; } -.chat-composite-bar-meta-item { - display: inline-flex; - align-items: center; - flex-shrink: 0; -} - -.chat-composite-bar-meta-item.chat-composite-bar-meta-workspace-item { - flex: 1 1 auto; - min-width: 0; -} - -/* Inline, auto-width layout only — the secondary-button sizing/colors come from the standard - `.monaco-text-button.small.secondary` styles. */ -.chat-composite-bar-meta-item .monaco-button.chat-composite-bar-meta-item-button { - display: inline-flex; - width: auto; - gap: 4px; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.chat-composite-bar-meta-item .monaco-button.chat-composite-bar-meta-workspace-button { - min-width: 0; - max-width: 100%; - overflow: hidden; -} - -.chat-composite-bar-meta-workspace-button .chat-composite-bar-meta-item-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Tighten the focus ring on these compact pills. The base `.monaco-text-button` - uses `outline-offset: 2px`, which on a small pill reads as a bloated ring - detached from the border — hug the border instead. */ -.chat-composite-bar-meta-item .monaco-button.chat-composite-bar-meta-item-button:focus { - outline-offset: 0 !important; -} - -.monaco-workbench .chat-composite-bar-meta-item-icon.codicon[class*='codicon-'] { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--vscode-codiconFontSize-compact, 12px); - height: var(--vscode-codiconFontSize-compact, 12px); - margin: 0; - font-size: var(--vscode-codiconFontSize-compact, 12px); - flex-shrink: 0; -} - -.chat-composite-bar-meta-added { - color: var(--vscode-chat-linesAddedForeground); -} - -.chat-composite-bar-meta-removed { - color: var(--vscode-chat-linesRemovedForeground); -} - /* Tabs row */ .chat-composite-bar-tabs-row { display: flex; diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index aaae2c48781876..3baf192e26a741 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -6,7 +6,7 @@ import './media/chatCompositeBar.css'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent, reset } from '../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; @@ -16,7 +16,6 @@ import { localize } from '../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { getUntitledSessionTitle } from '../../services/sessions/common/session.js'; -import { ActionRunner, IAction } from '../../../base/common/actions.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; import { MenuItemAction } from '../../../platform/actions/common/actions.js'; @@ -29,37 +28,19 @@ import { applySessionBarThemeColors } from './sessionBarStyles.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { SessionStatusIcon } from '../sessionStatusIcon.js'; -import { SessionHeaderMetaActionViewItem } from './sessionHeaderMetaActionViewItem.js'; - -/** - * An action runner for the session header toolbars that promotes the header's - * session to be the active session before running any contributed command. This - * ensures commands (e.g. View All Changes) operate on the clicked session even when - * a different session is currently active. - */ -class SessionActivatingActionRunner extends ActionRunner { - - constructor( - private readonly _getSession: () => IActiveSession | undefined, - private readonly _sessionsService: ISessionsService, - ) { - super(); - } - - protected override async runAction(action: IAction, context?: unknown): Promise { - const session = this._getSession(); - if (session) { - this._sessionsService.setActive(session); - } - await super.runAction(action, context); - } -} +import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; +import { getSessionWorkspaceDisplayInfo } from '../sessionWorkspace.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; /** * The session header shown at the top of a session view. It surfaces the session - * identity (status icon + title), a meta row (contributed workspace folder / - * changes / pull request pills), and the session toolbars (e.g. Run, Open in - * VS Code, New Chat). + * identity, optional workspace metadata, contributed metadata pills, and the + * session toolbars. * * It is intentionally decoupled from the {@link ChatCompositeBar} (the chat tab * strip) so the two surfaces evolve independently. The hosting view tells the @@ -71,6 +52,7 @@ export class SessionHeader extends Disposable { private readonly _iconEl: HTMLElement; private readonly _titleEl: HTMLElement; private readonly _titleTextEl: HTMLElement; + private readonly _workspaceMetaEl: HTMLElement; private readonly _metaRow: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; private readonly _metaToolbar: MenuWorkbenchToolBar; @@ -96,6 +78,8 @@ export class SessionHeader extends Disposable { private readonly _sessionTransfer = LocalSelectionTransfer.getInstance(); private readonly _metaActionsSignal: IObservable; + private readonly _showMetadataInChatInput: IObservable; + private readonly _workspaceHover = this._register(new MutableDisposable()); private readonly _statusIcon: SessionStatusIcon; @@ -118,9 +102,12 @@ export class SessionHeader extends Disposable { @IContextKeyService private readonly _contextKeyService: IContextKeyService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, + @IConfigurationService configurationService: IConfigurationService, + @IHoverService private readonly _hoverService: IHoverService, ) { super(); + this._showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, configurationService); this._container = $('.chat-composite-bar.session-header-bar'); // Header: a status icon column alongside a main column that stacks the title @@ -148,6 +135,9 @@ export class SessionHeader extends Disposable { this._titleTextEl = $('span.chat-composite-bar-session-title-text'); this._titleEl.appendChild(this._titleTextEl); + this._workspaceMetaEl = $('.chat-composite-bar-workspace-meta'); + titleRow.appendChild(this._workspaceMetaEl); + // Click the title to start an inline rename. Click is preferred over // mousedown so that initiating a drag from the title doesn't also // flip into edit mode. @@ -179,7 +169,7 @@ export class SessionHeader extends Disposable { // diff-stats action (opens the multi-file diff editor) and the GitHub // contribution contributes the pull request pill (opens the PR on GitHub), // each rendered as a compact secondary button pill via - // SessionHeaderMetaActionViewItem. + // ChatPillActionViewItem. const metaToolbarContainer = $('.chat-composite-bar-meta-toolbar'); this._metaRow.appendChild(metaToolbarContainer); // Commands contributed into the header meta toolbar (e.g. View All Changes) @@ -195,7 +185,7 @@ export class SessionHeader extends Disposable { // registers its own action view item via IActionViewItemService. actionViewItemProvider: (action, options) => { if (action instanceof MenuItemAction) { - return instantiationService.createInstance(SessionHeaderMetaActionViewItem, undefined, action, options); + return instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options); } return undefined; }, @@ -330,13 +320,29 @@ export class SessionHeader extends Disposable { const isQuickChat = session.isQuickChat?.read(reader) ?? false; this._titleTextEl.textContent = session.title.read(reader) || getUntitledSessionTitle(isQuickChat); this._titleEl.classList.toggle('editable', this._isTitleEditable()); + const showMetadataInChatInput = this._showMetadataInChatInput.read(reader); + const workspaceInfo = showMetadataInChatInput && !isQuickChat ? getSessionWorkspaceDisplayInfo(session, reader) : undefined; + this._workspaceMetaEl.classList.toggle('hidden', !workspaceInfo); + this._workspaceHover.clear(); + if (workspaceInfo) { + const label = $('span.chat-composite-bar-workspace-meta-label', undefined, workspaceInfo.label); + reset( + this._workspaceMetaEl, + $('span.chat-composite-bar-workspace-meta-separator', { 'aria-hidden': 'true' }, '·'), + $(`span.chat-composite-bar-workspace-meta-icon${ThemeIcon.asCSSSelector(workspaceInfo.icon)}`, { 'aria-hidden': 'true' }), + label, + ); + this._workspaceHover.value = this._hoverService.setupDelayedHover(label, { content: workspaceInfo.label }); + } else { + reset(this._workspaceMetaEl); + } // Meta row: contributed action pills (workspace folder · diff stats · pull request). // Reading the signal re-runs this on menu changes. this._metaActionsSignal.read(reader); const hasMetaActions = !this._metaToolbar.isEmpty(); - this._metaRow.style.display = hasMetaActions ? '' : 'none'; + this._metaRow.style.display = !showMetadataInChatInput && hasMetaActions ? '' : 'none'; this._onDidChangeHeight.fire(); } diff --git a/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts b/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts deleted file mode 100644 index d273f26f513d3e..00000000000000 --- a/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts +++ /dev/null @@ -1,173 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, addDisposableListener, EventType, reset } from '../../../base/browser/dom.js'; -import { BaseActionViewItem, IActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; -import { Button } from '../../../base/browser/ui/button/button.js'; -import { IAction } from '../../../base/common/actions.js'; -import { isMacintosh } from '../../../base/common/platform.js'; -import { defaultButtonStyles } from '../../../platform/theme/browser/defaultStyles.js'; - -/** - * Renders an action contributed into the session header meta row ({@link Menus.SessionHeaderMeta}) - * as a secondary {@link Button} with an inline `icon title` label so every contributed action reads - * consistently. Used as the default rendering for meta actions that don't register their own - * action view item. - * - * Subclasses can override {@link getLabelText} (e.g. the pull request `#`) or append dynamic - * content via {@link getAdditionalLabelContent} (e.g. the changes diff stats), calling - * {@link updateLabel} when it changes. - */ -export class SessionHeaderMetaActionViewItem extends BaseActionViewItem { - - protected button: Button | undefined; - - constructor(context: unknown, action: IAction, options: IActionViewItemOptions) { - super(context, action, options); - } - - override render(container: HTMLElement): void { - this.element = container; - container.classList.add('chat-composite-bar-meta-item'); - - const button = this.button = this._register(new Button(container, { secondary: true, small: true, ...defaultButtonStyles })); - button.element.classList.add('monaco-text-button', 'chat-composite-bar-meta-item-button'); - this._register(addDisposableListener(button.element.ownerDocument.body, EventType.MOUSE_DOWN, event => { - if (event.button === 0 && (!isMacintosh || !event.ctrlKey) && this.hasOpenDropdown() && button.element.contains(event.target as Node | null)) { - event.stopPropagation(); - } - })); - this._register(button.onDidClick(() => { - if (this._action.enabled) { - this.onDidClickButton(); - } - })); - - this.updateLabel(); - this.updateEnabled(); - this.updateTooltip(); - } - - /** - * Whether this item currently owns an open dropdown. - */ - protected hasOpenDropdown(): boolean { - return false; - } - - /** - * Invoked when the pill is activated. Runs the action by default; subclasses can - * override to present their own affordance (e.g. a picker when the pill stands - * for several items). - */ - protected onDidClickButton(): void { - this.actionRunner.run(this._action, this._context); - } - - override focus(): void { - this.button?.focus(); - } - - override blur(): void { - if (this.button) { - this.button.element.tabIndex = -1; - this.button.element.blur(); - } - } - - override setFocusable(focusable: boolean): void { - if (this.button) { - this.button.element.tabIndex = focusable ? 0 : -1; - } - } - - override isFocused(): boolean { - return !!this.button?.hasFocus(); - } - - protected override updateClass(): void { - this.updateLabel(); - } - - protected override updateEnabled(): void { - if (this.button) { - this.button.enabled = this._action.enabled; - } - } - - protected override updateLabel(): void { - if (!this.button) { - return; - } - reset(this.button.element, ...this.getLabelContent()); - } - - protected override updateAriaLabel(): void { - const ariaLabel = this.getAriaLabel(); - if (ariaLabel) { - this.button?.element.setAttribute('aria-label', ariaLabel); - } else { - this.button?.element.removeAttribute('aria-label'); - } - } - - /** - * The button's accessible name. Defaults to {@link getTooltip}. Subclasses that render - * meaningful state in the visible label (e.g. the workspace name, or diff counts) should - * override this so screen readers announce the same information that is shown visually. - */ - protected getAriaLabel(): string | undefined { - return this.getTooltip(); - } - - protected override getTooltip(): string | undefined { - // `MenuItemAction.tooltip` defaults to '' when not provided, which would - // leave the pill without a managed hover and an empty aria-label. Fall - // back to the action label so the pill is always labelled. - return this._action.tooltip || this._action.label || undefined; - } - - private getLabelContent(): Array { - const content: Array = []; - - const iconElement = this.getIconElement(); - if (iconElement) { - content.push(iconElement); - } - - const labelText = this.getLabelText(); - if (labelText) { - content.push($('span.chat-composite-bar-meta-item-label', undefined, labelText)); - } - - content.push(...this.getAdditionalLabelContent()); - return content; - } - - /** - * The leading icon element. Defaults to the action's icon (without color). - */ - protected getIconElement(): HTMLElement | undefined { - const iconClasses = this._action.class?.split(' ').filter(cssClass => !!cssClass); - if (!iconClasses?.length) { - return undefined; - } - return $(`span.chat-composite-bar-meta-item-icon${iconClasses.map(cssClass => `.${cssClass}`).join('')}`); - } - - /** - * The button's title text. Defaults to the action label. - */ - protected getLabelText(): string { - return this._action.label; - } - - /** - * Additional label content rendered after the title. Defaults to none. - */ - protected getAdditionalLabelContent(): Array { - return []; - } -} diff --git a/src/vs/sessions/browser/sessionActionRunner.ts b/src/vs/sessions/browser/sessionActionRunner.ts new file mode 100644 index 00000000000000..6612387bc7aec2 --- /dev/null +++ b/src/vs/sessions/browser/sessionActionRunner.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ActionRunner, IAction } from '../../base/common/actions.js'; +import { ISessionsService } from '../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../services/sessions/common/sessionsManagement.js'; + +/** Activates the originating session before running a session-scoped action. */ +export class SessionActivatingActionRunner extends ActionRunner { + + constructor( + private readonly _getSession: () => IActiveSession | undefined, + private readonly _sessionsService: ISessionsService, + ) { + super(); + } + + protected override async runAction(action: IAction, context?: unknown): Promise { + const session = this._getSession(); + if (session) { + this._sessionsService.setActive(session); + } + await super.runAction(action, context); + } +} diff --git a/src/vs/sessions/browser/sessionWorkspace.ts b/src/vs/sessions/browser/sessionWorkspace.ts new file mode 100644 index 00000000000000..a23cc17e24c506 --- /dev/null +++ b/src/vs/sessions/browser/sessionWorkspace.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../base/common/codicons.js'; +import { IReader } from '../../base/common/observable.js'; +import { ThemeIcon } from '../../base/common/themables.js'; +import { getSessionWorkspaceKind, ISession, SessionWorkspaceKind } from '../services/sessions/common/session.js'; + +export interface ISessionWorkspaceDisplayInfo { + readonly label: string; + readonly icon: ThemeIcon; + readonly workingDirectoryPath: string | undefined; + readonly branch: string | undefined; + readonly worktreePending: boolean; +} + +/** Returns the workspace presentation shared by the session header and Files pill. */ +export function getSessionWorkspaceDisplayInfo(session: ISession | undefined, reader: IReader): ISessionWorkspaceDisplayInfo | undefined { + const workspace = session?.workspace.read(reader); + if (!workspace?.label) { + return undefined; + } + + const worktreePending = session?.worktreePending?.read(reader) ?? false; + const kind = getSessionWorkspaceKind(workspace, worktreePending); + const icon = workspace.typeIcon ?? (kind === SessionWorkspaceKind.Virtual ? Codicon.cloudCompact : kind === SessionWorkspaceKind.Folder ? Codicon.folderCompact : Codicon.worktreeCompact); + const folder = workspace.folders[0]; + const branch = worktreePending ? undefined : folder?.gitRepository?.branchName?.trim() || undefined; + const workingDirectoryPath = worktreePending ? undefined : folder?.workingDirectory.fsPath; + return { label: workspace.label, icon, workingDirectoryPath, branch, worktreePending }; +} diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index deda8edc5edf24..09d79fe367365c 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -13,6 +13,8 @@ import type { ResolveSessionConfigResult } from '../../platform/agentHost/common */ export const DOCK_DETAIL_PANEL_SETTING = 'sessions.layout.singlePaneDetailPanel'; +export const SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING = 'chat.agentSessions.showSessionMetadataInInput'; + export function isSessionConfigComplete(config: ResolveSessionConfigResult): boolean { return (config.schema.required ?? []).every(property => config.values[property] !== undefined); } diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index 65891d05b4cd10..c42c3fc1b3bd58 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -26,7 +26,7 @@ import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/b import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; +import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { IsQuickChatSessionContext, SessionHasChangesContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -235,7 +235,7 @@ interface IDiffStats { /** * Renders the {@link ViewAllChangesAction} menu item contributed into {@link Menus.SessionHeaderMeta} * (the session header meta row) as a ` files +insertions -deletions` pill. It extends the - * generic {@link SessionHeaderMetaActionViewItem} (so the icon and label render consistently with other + * generic {@link ChatPillActionViewItem} (so the icon and label render consistently with other * meta actions) and appends the session's live aggregate diff stats. Activating the item runs the * action, which opens the multi-file diff editor. * @@ -245,7 +245,7 @@ interface IDiffStats { * changeset the provider marks as {@link ISessionChangeset.isDefault} (or the session's * top-level {@link IActiveSession.changes} when none is default). */ -export class ViewAllChangesActionViewItem extends SessionHeaderMetaActionViewItem { +export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { private readonly _diffStatsObs: IObservable; @@ -309,8 +309,8 @@ export class ViewAllChangesActionViewItem extends SessionHeaderMetaActionViewIte protected override getAdditionalLabelContent(): Array { const { insertions, deletions } = this._diffStatsObs.get(); return [ - $('span.chat-composite-bar-meta-added', undefined, `+${insertions}`), - $('span.chat-composite-bar-meta-removed', undefined, `-${deletions}`), + $('span.chat-pill-added', undefined, `+${insertions}`), + $('span.chat-pill-removed', undefined, `-${deletions}`), ]; } diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index aaeea6145e50a4..745aeeb2b0e4bd 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -9,6 +9,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import product from '../../../../platform/product/common/product.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService, inheritableSessionTarget } from '../../../services/sessions/common/sessionsManagement.js'; @@ -47,6 +48,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; class NewChatInSessionsWindowAction extends Action2 { @@ -149,5 +151,11 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, description: localize('chat.agentSessions.scopedInputHistory', "Controls whether chat input history in the Agents Window is scoped to the current session. Disable this to use shared input history across sessions."), }, + [SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING]: { + type: 'boolean', + default: product.quality !== 'stable', + scope: ConfigurationScope.APPLICATION, + description: localize('chat.agentSessions.showSessionMetadataInInput', "Controls whether session metadata such as changes, pull requests, and issues appears above the chat input instead of in the session header."), + }, }, }); diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css b/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css deleted file mode 100644 index 56cd36a24ae962..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css +++ /dev/null @@ -1,41 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Several pills can share the row above the input (browsers, background - activities, turn status), so a pill shrinks below its content and ellipsizes - its label rather than pushing its neighbours out of the row. The cap keeps a - single long label from crowding out the other pills when there is room. */ -.session-activity-pill { - display: inline-flex; - flex: 0 1 auto; - min-width: 0; -} - -.session-activity-pill.hidden { - display: none; -} - -.session-activity-pill .session-activity-pill-button { - display: inline-flex; - width: fit-content; - min-width: 0; - max-width: 280px; - gap: var(--vscode-spacing-size40); - overflow: hidden; - white-space: nowrap; - touch-action: manipulation; -} - -.session-activity-pill .session-activity-pill-button > span:not(.codicon) { - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.session-activity-pill .session-activity-pill-button .codicon { - font-size: var(--vscode-codiconFontSize-compact); - flex-shrink: 0; -} diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css index 431c40ba336969..82b0df92352d38 100644 --- a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css +++ b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css @@ -3,25 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Floating status pills centered above the chat input. The pills themselves are - the shared `.chat-turn-pills` widget (styled in chatTurnPills.css) and the - session activity pills (sessionActivityPill.css); this file only positions and - centers them above the input. */ +/* Horizontally scrollable status pills above the chat input. */ .session-chat-input-toolbar { + width: 100%; + min-width: 0; +} + +.session-chat-input-toolbar-content { display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; gap: var(--vscode-spacing-size60); + width: 100%; min-width: 0; + box-sizing: border-box; padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size60) 0; } -/* The turn pills size to their content and deliberately don't shrink internally, - so squeezing them would spill their pills over the activity pills next to - them. Keep them at their natural width and let the activity pills, which - ellipsize their labels, absorb the shrinking instead. */ -.session-chat-input-toolbar > .chat-turn-pills { +.session-chat-input-toolbar-content > .chat-pills, +.session-chat-input-toolbar-content > .session-activity-pill { flex-shrink: 0; } @@ -29,3 +30,28 @@ display: none; } +/* Every pill is hidden by the user but data exists: keep a slim strip so its + context menu stays reachable and the pills can be shown again. */ +.session-chat-input-toolbar.empty .session-chat-input-toolbar-content { + min-height: var(--vscode-spacing-size120); +} + +.session-chat-input-toolbar > .scrollbar > .slider { + background: transparent; +} + +.session-chat-input-toolbar > .scrollbar.horizontal > .slider::before { + content: ''; + position: absolute; + inset: var(--vscode-strokeThickness); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-scrollbarSlider-background); +} + +.session-chat-input-toolbar > .scrollbar > .slider:hover::before { + background: var(--vscode-scrollbarSlider-hoverBackground); +} + +.session-chat-input-toolbar > .scrollbar > .slider.active::before { + background: var(--vscode-scrollbarSlider-activeBackground); +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts b/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts deleted file mode 100644 index 03ef6d2f233d4d..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts +++ /dev/null @@ -1,169 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $ } from '../../../../base/browser/dom.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { onUnexpectedError } from '../../../../base/common/errors.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import './media/sessionActivityPill.css'; - -/** One entry of a pill, rendered as the button label or as a picker row. */ -export interface ISessionActivity { - readonly label: string; - readonly icon: ThemeIcon; -} - -/** A named section of the picker; sections without activities are skipped. */ -export interface ISessionActivityCategory { - readonly title: string; - readonly activities: readonly T[]; -} - -/** The button content when a pill stands for more than one activity. */ -export interface ISessionActivitySummary { - readonly label: string; - readonly icon: ThemeIcon; - readonly ariaLabel: string; -} - -export interface ISessionActivityPillOptions { - /** Extra class on the pill root, for fixtures and per-pill styling. */ - readonly className: string; - /** Identifies the pill's picker to the action widget service. */ - readonly widgetId: string; - /** Accessible name of the picker shown for more than one activity. */ - readonly getWidgetAriaLabel: () => string; - /** Button content for more than one activity; a single activity renders itself. */ - readonly getSummary: (activities: readonly T[]) => ISessionActivitySummary; - readonly openActivity: (activity: T) => void | Promise; -} - -/** - * A compact button standing for a set of activities. A single activity is shown - * with its own icon and label and is opened directly; more than one shows the - * consumer's summary and opens a picker grouped by category. The widget owns - * only the presentation — which activities exist, how they are grouped, and how - * they are labelled is up to the consumer. - */ -export class SessionActivityPill extends Disposable { - - readonly element: HTMLElement; - readonly isVisible: IObservable; - - private readonly _button: Button; - private readonly _isVisible = observableValue(this, false); - private _categories: readonly ISessionActivityCategory[] = []; - private _activities: readonly T[] = []; - - constructor( - private readonly _options: ISessionActivityPillOptions, - private readonly _actionWidgetService: IActionWidgetService, - ) { - super(); - - this.element = $(`.session-activity-pill.${_options.className}.hidden`); - this.isVisible = this._isVisible; - this._button = this._register(new Button(this.element, { secondary: true, small: true, supportIcons: true, ...defaultButtonStyles })); - this._button.element.classList.add('session-activity-pill-button'); - this._register(this._button.onDidClick(() => this._onDidClick())); - } - - setCategories(categories: readonly ISessionActivityCategory[]): void { - this._categories = categories.filter(category => category.activities.length > 0); - this._activities = this._categories.flatMap(category => category.activities); - this._render(); - } - - private _render(): void { - const count = this._activities.length; - this._isVisible.set(count > 0, undefined); - this.element.classList.toggle('hidden', count === 0); - if (count === 0) { - return; - } - - let label: string; - let accessibleLabel: string; - if (count === 1) { - const activity = this._activities[0]; - label = `$(${activity.icon.id}) ${activity.label}`; - accessibleLabel = localize('sessionActivityPill.open', "Open {0}", activity.label); - } else { - const summary = this._options.getSummary(this._activities); - label = `$(${summary.icon.id}) ${summary.label} $(${Codicon.chevronDown.id})`; - accessibleLabel = summary.ariaLabel; - } - - this._button.label = label; - this._button.setTitle(accessibleLabel); - this._button.setAriaLabel(accessibleLabel); - } - - private _onDidClick(): void { - if (this._activities.length === 1) { - this._openActivity(this._activities[0]); - return; - } - if (this._activities.length > 1) { - this._showPicker(); - } - } - - private _openActivity(activity: T): void { - Promise.resolve(this._options.openActivity(activity)).catch(onUnexpectedError); - } - - private _showPicker(): void { - if (this._actionWidgetService.isVisible) { - return; - } - - const items: IActionListItem[] = []; - for (const category of this._categories) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); - } - items.push({ kind: ActionListItemKind.Header, label: category.title, group: { title: category.title } }); - for (const activity of category.activities) { - items.push({ - kind: ActionListItemKind.Action, - label: activity.label, - group: { title: '', icon: activity.icon }, - item: activity, - }); - } - } - - const triggerElement = this._button.element; - const delegate: IActionListDelegate = { - onSelect: activity => { - this._actionWidgetService.hide(); - this._openActivity(activity); - }, - onHide: () => triggerElement.focus(), - }; - this._actionWidgetService.show( - this._options.widgetId, - false, - items, - delegate, - triggerElement, - undefined, - [], - { - getAriaLabel: item => item.label ?? '', - getWidgetAriaLabel: () => this._options.getWidgetAriaLabel(), - }, - { minWidth: 220, maxWidth: 420 }, - ); - } -} diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts new file mode 100644 index 00000000000000..635e5be871381e --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, IReader } from '../../../../base/common/observable.js'; +import { basename, getComparisonKey } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { SessionArtifactKind, SessionFileOperation, type ISessionArtifact, type ISessionFile } from '../../../services/sessions/common/session.js'; +import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; + +const artifactIcons: ReadonlyMap = new Map([ + [SessionArtifactKind.PullRequest, Codicon.gitPullRequest], + [SessionArtifactKind.Issue, Codicon.issues], + [SessionArtifactKind.Commit, Codicon.gitCommit], + [SessionArtifactKind.Website, Codicon.globe], + [SessionArtifactKind.Resource, Codicon.link], +]); + +/** Section order and titles, matching the order artifacts are offered in. */ +const sectionOrder: readonly { readonly kind: SessionArtifactKind; readonly title: string }[] = [ + { kind: SessionArtifactKind.PullRequest, title: localize('sessionArtifacts.pullRequests', "Pull Requests") }, + { kind: SessionArtifactKind.Issue, title: localize('sessionArtifacts.issues', "Issues") }, + { kind: SessionArtifactKind.Commit, title: localize('sessionArtifacts.commits', "Commits") }, + { kind: SessionArtifactKind.Website, title: localize('sessionArtifacts.websites', "Websites") }, + { kind: SessionArtifactKind.File, title: localize('sessionArtifacts.files', "Files") }, + { kind: SessionArtifactKind.Resource, title: localize('sessionArtifacts.resources', "Resources") }, +]; + +/** What an artifact entry needs from the surrounding surface to be activated. */ +export interface ISessionArtifactActions { + openExternal(link: URI): void; + openResource(uri: URI): void; + copy(text: string): void; +} + +function artifactValueKey(artifact: ISessionArtifact): string { + if (artifact.uri) { + return getComparisonKey(artifact.uri); + } + return (artifact.link?.toString() ?? artifact.commitHash ?? artifact.id).toLowerCase(); +} + +function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): IChatPillEntry | undefined { + if (artifact.kind === SessionArtifactKind.File) { + return artifact.uri + ? { id: artifact.id, label: basename(artifact.uri), resource: artifact.uri, open: () => actions.openResource(artifact.uri!) } + : undefined; + } + + const icon = artifactIcons.get(artifact.kind) ?? Codicon.archive; + if (artifact.kind === SessionArtifactKind.Commit) { + if (!artifact.link) { + return undefined; + } + const link = artifact.link; + const copyAction = artifact.commitHash + ? [toAction({ + id: 'sessions.artifacts.copyCommitHash', + label: localize('sessionArtifacts.copyCommitHash', "Copy Commit Hash"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => actions.copy(artifact.commitHash!), + })] + : []; + return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, open: () => actions.openExternal(link) }; + } + + if (artifact.kind === SessionArtifactKind.Resource) { + return artifact.uri + ? { id: artifact.id, label: artifact.label, icon, open: () => actions.openResource(artifact.uri!) } + : undefined; + } + + return artifact.link + ? { id: artifact.id, label: artifact.label, icon, open: () => actions.openExternal(artifact.link!) } + : undefined; +} + +/** + * Builds the artifact sections shown in the pill: the agent-set artifacts plus + * the previewable files the session wrote outside its workspace, de-duplicated + * with the agent's own entries winning. + */ +export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions): readonly IChatPillSection[] { + const entriesByKind = new Map(); + const seen = new Set(); + + for (const artifact of artifacts) { + const entry = toEntry(artifact, actions); + if (!entry || seen.has(artifactValueKey(artifact))) { + continue; + } + seen.add(artifactValueKey(artifact)); + const entries = entriesByKind.get(artifact.kind) ?? []; + entries.push(entry); + entriesByKind.set(artifact.kind, entries); + } + + for (const file of externalFiles) { + if (file.operation === SessionFileOperation.Deleted || !previewKind(file.uri) || seen.has(getComparisonKey(file.uri))) { + continue; + } + seen.add(getComparisonKey(file.uri)); + const entries = entriesByKind.get(SessionArtifactKind.File) ?? []; + entries.push({ id: file.uri.toString(), label: basename(file.uri), resource: file.uri, open: () => actions.openResource(file.uri) }); + entriesByKind.set(SessionArtifactKind.File, entries); + } + + const sections: IChatPillSection[] = []; + for (const { kind, title } of sectionOrder) { + const entries = entriesByKind.get(kind); + if (entries?.length) { + sections.push({ title, entries }); + } + } + return sections; +} + +/** Publishes a session's artifact sections for the chat input pill. */ +export class SessionArtifacts extends Disposable { + + readonly sections: IObservable; + + constructor( + session: IObservable, + @IClipboardService private readonly _clipboardService: IClipboardService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IOpenerService private readonly _openerService: IOpenerService, + ) { + super(); + + this.sections = derived(this, reader => { + const current = session.read(reader); + if (!current) { + return []; + } + return buildSessionArtifactSections( + current.artifacts?.read(reader) ?? [], + this._readExternalFiles(current, reader), + this._actions(), + ); + }); + } + + private _readExternalFiles(session: IActiveSession, reader: IReader): readonly ISessionFile[] { + return session.externalChanges?.read(reader) ?? []; + } + + private _actions(): ISessionArtifactActions { + return { + openExternal: link => { void this._openerService.open(link, { openExternal: true }); }, + openResource: uri => { + if (previewKind(uri)) { + void openChatTurnFile({ uri, kind: previewKind(uri)!, created: false }, this._openerService, this._configurationService); + return; + } + void this._openerService.open(uri, { fromUserGesture: true }); + }, + copy: text => { void this._clipboardService.writeText(text); }, + }; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts index 982539f7d57803..60571898c9ffbc 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts @@ -5,115 +5,93 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, IReader } from '../../../../base/common/observable.js'; +import { derived, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat, isActiveSessionStatus } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionActivity, ISessionActivitySummary, SessionActivityPill } from './sessionActivityPill.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; const SUBAGENT_LABEL_MAX_LENGTH = 30; -interface ISubagentActivity extends ISessionActivity { - /** The subagent chat to open, or `undefined` for a fake activity from debug data. */ - readonly chat: IChat | undefined; -} - -/** - * The activities this pill lists. Further kinds join this union; once more than - * one kind can be listed at once, the summary needs a generic mixed-kind label. - */ -type IBackgroundActivity = ISubagentActivity; +/** Presentation of the subagents pill. */ +export const sessionSubagentsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionBackgroundActivities', + icon: Codicon.agent, + title: localize('backgroundActivities.ariaLabel', "Background Activities"), + summaryLabel: count => localize('backgroundActivities.activeSubagents', "{0} Active Subagents", count), + summaryAriaLabel: count => localize('backgroundActivities.show', "Show {0} background activities", count), +}; /** - * Lists the background activities of the viewed chat as one compact pill. Today - * those are the chat's running subagents. Browsers have their own pill, see + * Supplies the background activities of the viewed chat to its pill. Today those + * are the chat's running subagents; browsers have their own pill, see * `SessionBrowsersControl`. */ export class SessionBackgroundActivitiesControl extends Disposable { - readonly element: HTMLElement; - readonly isVisible: IObservable; + /** The pill's sections, empty while the user has the pill hidden. */ + readonly sections: IObservable; + /** Whether there are activities to show, regardless of the user's visibility choice. */ + readonly hasData: IObservable; - private readonly _pill: SessionActivityPill; - private _currentSession: IActiveSession | undefined; - private _runningSubagents: readonly ISubagentActivity[] = []; - private _debugData: ISessionChatPillsDebugData | undefined; + private readonly _debugData = observableValue(this, undefined); constructor( - private readonly _session: IObservable, - private readonly _chat: IObservable, - private readonly _enabled: IObservable, - @IActionWidgetService actionWidgetService: IActionWidgetService, + session: IObservable, + chat: IObservable, + enabled: IObservable, + visible: IObservable, @ISessionsService private readonly _sessionsService: ISessionsService, ) { super(); - this._pill = this._register(new SessionActivityPill({ - className: 'session-background-activities', - widgetId: 'sessionBackgroundActivities', - getWidgetAriaLabel: () => localize('backgroundActivities.ariaLabel', "Background Activities"), - getSummary: activities => this._summary(activities), - openActivity: activity => this._openActivity(activity), - }, actionWidgetService)); - this.element = this._pill.element; - this.isVisible = this._pill.isVisible; - - this._register(autorun(reader => { - const session = this._session.read(reader); - const chat = this._chat.read(reader); - const enabled = this._enabled.read(reader); - this._currentSession = session; - this._runningSubagents = enabled && session && chat ? this._collectRunningSubagents(session, chat, reader) : []; - this._refresh(); - })); + const allSections = derived(this, reader => { + const debugData = this._debugData.read(reader); + const currentSession = session.read(reader); + const currentChat = chat.read(reader); + const subagents = debugData + ? debugData.subagents.map(label => this._entry(label, undefined, currentSession)) + : enabled.read(reader) && currentSession && currentChat + ? this._collectRunningSubagents(currentSession, currentChat, reader) + : []; + return subagents.length > 0 + ? [{ title: localize('backgroundActivities.subagents', "Subagents"), entries: subagents }] + : []; + }); + + this.hasData = derived(this, reader => getChatPillEntries(allSections.read(reader)).length > 0); + this.sections = derived(this, reader => visible.read(reader) ? allSections.read(reader) : []); } setDebugData(data: ISessionChatPillsDebugData | undefined): void { - this._debugData = data; - this._refresh(); + this._debugData.set(data, undefined); } - private _collectRunningSubagents(session: IActiveSession, parentChat: IChat, reader: IReader): ISubagentActivity[] { + private _collectRunningSubagents(session: IActiveSession, parentChat: IChat, reader: IReader): IChatPillEntry[] { return session.chats.read(reader) .filter(chat => chat.origin?.kind === ChatOriginKind.Tool && !!chat.origin.parentChat && isEqual(chat.origin.parentChat, parentChat.resource) && isActiveSessionStatus(chat.status.read(reader))) - .map(chat => ({ - chat, - icon: Codicon.agent, - label: this._subagentLabel(chat.title.read(reader)), - })); + .map(chat => this._entry(chat.title.read(reader), chat, session)); } - private _subagentLabel(title: string): string { - const label = title.trim() || localize('backgroundActivities.subagent', "Subagent"); - return label.length > SUBAGENT_LABEL_MAX_LENGTH ? `${label.slice(0, SUBAGENT_LABEL_MAX_LENGTH)}...` : label; - } - - private _refresh(): void { - const subagents: readonly ISubagentActivity[] = this._debugData - ? this._debugData.subagents.map(label => ({ label, icon: Codicon.agent, chat: undefined })) - : this._runningSubagents; - this._pill.setCategories([{ title: localize('backgroundActivities.subagents', "Subagents"), activities: subagents }]); - } - - private _summary(activities: readonly IBackgroundActivity[]): ISessionActivitySummary { + private _entry(title: string, chat: IChat | undefined, session: IActiveSession | undefined): IChatPillEntry { + const name = title.trim() || localize('backgroundActivities.subagent', "Subagent"); return { + id: chat?.resource.toString() ?? name, + label: name.length > SUBAGENT_LABEL_MAX_LENGTH ? `${name.slice(0, SUBAGENT_LABEL_MAX_LENGTH)}...` : name, icon: Codicon.agent, - label: localize('backgroundActivities.activeSubagents', "{0} Active Subagents", activities.length), - ariaLabel: localize('backgroundActivities.show', "Show {0} background activities", activities.length), + open: () => { + if (chat && session) { + this._sessionsService.openChat(session, chat.resource); + } + }, }; } - - private _openActivity(activity: IBackgroundActivity): void { - if (activity.chat && this._currentSession) { - this._sessionsService.openChat(this._currentSession, activity.chat.resource); - } - } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts index 2f46abdd93101e..b645265aef92a5 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts @@ -5,98 +5,85 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, IReader } from '../../../../base/common/observable.js'; +import { derived, IObservable, IReader, observableSignal, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionActivity, ISessionActivitySummary, SessionActivityPill } from './sessionActivityPill.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -interface IBrowserActivity extends ISessionActivity { - /** The browser to open, or `undefined` for a fake activity from debug data. */ - readonly input: BrowserEditorInput | undefined; -} +/** Presentation of the browsers pill. */ +export const sessionBrowsersPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionBrowsers', + icon: Codicon.globe, + title: localize('browsers.ariaLabel', "Browsers"), + summaryLabel: count => localize('browsers.activeBrowsers', "{0} Active Browsers", count), + summaryAriaLabel: count => localize('browsers.show', "Show {0} browsers", count), +}; -/** Lists the live browsers of the viewed chat (and its subagents) as one compact pill. */ +/** Supplies the live browsers of the viewed chat (and its subagents) to its pill. */ export class SessionBrowsersControl extends Disposable { - readonly element: HTMLElement; - readonly isVisible: IObservable; + /** The pill's sections, empty while the user has the pill hidden. */ + readonly sections: IObservable; + /** Whether there are browsers to show, regardless of the user's visibility choice. */ + readonly hasData: IObservable; - private readonly _pill: SessionActivityPill; + private readonly _debugData = observableValue(this, undefined); + /** Browser titles and the known-browser set change outside the observable graph. */ + private readonly _browsersChanged = observableSignal(this); private readonly _browserListeners = this._register(new MutableDisposable()); - /** Chats whose browsers belong to this pill: the viewed chat and its subagents. */ - private _ownerIds: ReadonlySet = new Set(); - private _currentChat: IChat | undefined; - private _enabledValue = false; - private _debugData: ISessionChatPillsDebugData | undefined; constructor( - private readonly _session: IObservable, - private readonly _chat: IObservable, - private readonly _enabled: IObservable, + session: IObservable, + chat: IObservable, + enabled: IObservable, + visible: IObservable, @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, - @IActionWidgetService actionWidgetService: IActionWidgetService, @IEditorService private readonly _editorService: IEditorService, ) { super(); - this._pill = this._register(new SessionActivityPill({ - className: 'session-browsers', - widgetId: 'sessionBrowsers', - getWidgetAriaLabel: () => localize('browsers.ariaLabel', "Browsers"), - getSummary: activities => this._summary(activities), - openActivity: activity => this._openActivity(activity), - }, actionWidgetService)); - this.element = this._pill.element; - this.isVisible = this._pill.isVisible; - - this._register(autorun(reader => { - const session = this._session.read(reader); - const chat = this._chat.read(reader); - this._currentChat = chat; - this._enabledValue = this._enabled.read(reader); - // Read the chat list through the reader so browsers registered by a - // subagent show up as soon as that subagent joins the session. - this._ownerIds = session && chat ? this._collectOwnerIds(session, chat, reader) : new Set(); - this._refresh(); - })); + const allSections = derived(this, reader => { + this._browsersChanged.read(reader); + const debugData = this._debugData.read(reader); + const currentSession = session.read(reader); + const currentChat = chat.read(reader); + const browsers = debugData + ? debugData.browsers.map(label => this._entry(label, undefined, currentChat)) + : enabled.read(reader) && currentSession && currentChat + // Read the chat list through the reader so browsers registered by a + // subagent show up as soon as that subagent joins the session. + ? this._collectBrowsers(this._collectOwnerIds(currentSession, currentChat, reader), currentChat) + : []; + return browsers.length > 0 + ? [{ title: localize('browsers.browsers', "Browsers"), entries: browsers }] + : []; + }); + + this.hasData = derived(this, reader => getChatPillEntries(allSections.read(reader)).length > 0); + this.sections = derived(this, reader => visible.read(reader) ? allSections.read(reader) : []); + this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); this._refreshBrowserListeners(); } setDebugData(data: ISessionChatPillsDebugData | undefined): void { - this._debugData = data; - this._refresh(); + this._debugData.set(data, undefined); } private _refreshBrowserListeners(): void { const store = new DisposableStore(); this._browserListeners.value = store; for (const input of this._browserViewService.getKnownBrowserViews().values()) { - store.add(input.onDidChangeLabel(() => this._refresh())); + store.add(input.onDidChangeLabel(() => this._browsersChanged.trigger(undefined))); } - this._refresh(); - } - - private _refresh(): void { - const activities = this._debugData - ? this._debugData.browsers.map(label => ({ label, icon: Codicon.globe, input: undefined })) - : this._enabledValue ? this._collectBrowserActivities() : []; - this._pill.setCategories([{ title: localize('browsers.browsers', "Browsers"), activities }]); - } - - private _summary(activities: readonly IBrowserActivity[]): ISessionActivitySummary { - return { - icon: Codicon.globe, - label: localize('browsers.activeBrowsers', "{0} Active Browsers", activities.length), - ariaLabel: localize('browsers.show', "Show {0} browsers", activities.length), - }; + this._browsersChanged.trigger(undefined); } private _collectOwnerIds(session: IActiveSession, chat: IChat, reader: IReader): ReadonlySet { @@ -109,39 +96,44 @@ export class SessionBrowsersControl extends Disposable { return ownerIds; } - private _collectBrowserActivities(): IBrowserActivity[] { - const activities: IBrowserActivity[] = []; + private _collectBrowsers(ownerIds: ReadonlySet, chat: IChat | undefined): IChatPillEntry[] { + const entries: IChatPillEntry[] = []; for (const input of this._browserViewService.getKnownBrowserViews().values()) { const ownerId = input.model?.owner.sessionId; - if (ownerId && this._ownerIds.has(ownerId)) { - activities.push({ - input, - icon: Codicon.globe, - label: input.title?.trim() || localize('browsers.browser', "Browser"), - }); + if (ownerId && ownerIds.has(ownerId)) { + entries.push(this._entry(input.title?.trim() || localize('browsers.browser', "Browser"), input, chat)); } } - return activities; + return entries; + } + + private _entry(label: string, input: BrowserEditorInput | undefined, chat: IChat | undefined): IChatPillEntry { + return { + id: input?.id ?? label, + label, + icon: Codicon.globe, + open: () => { void this._openBrowser(input, chat); }, + }; } - private async _openActivity(activity: IBrowserActivity): Promise { - if (!activity.input) { + private async _openBrowser(input: BrowserEditorInput | undefined, chat: IChat | undefined): Promise { + if (!input) { return; } - const input = this._getBrowserInputToOpen(activity.input); - const existing = this._editorService.findEditors(input.resource) - .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === input.id); + const target = this._getBrowserInputToOpen(input, chat); + const existing = this._editorService.findEditors(target.resource) + .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === target.id); const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); - await this._editorService.openEditor(input, undefined, targetGroup); + await this._editorService.openEditor(target, undefined, targetGroup); } - private _getBrowserInputToOpen(input: BrowserEditorInput): BrowserEditorInput { + private _getBrowserInputToOpen(input: BrowserEditorInput, chat: IChat | undefined): BrowserEditorInput { const url = input.url; if (input.model?.sharingState === BrowserViewSharingState.Shared || !url) { return input; } - const activeSessionId = this._currentChat?.resource.toString(); + const activeSessionId = chat?.resource.toString(); const shared = [...this._browserViewService.getContextualBrowserViews({ activeSessionId }).values()] .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)); return shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index ee5cdf91df0075..e40a5a468a8be2 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -3,90 +3,109 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $ } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ChatTurnPillsWidget, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, IPreviewFile, observeTurnStatusPillsEnabled, openChatTurnFile, previewFilesEqual, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID, ChatTurnPillsProvider, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { SessionArtifacts } from './sessionArtifacts.js'; +import { chatCustomizationPillOptions, SessionCustomizations, SESSION_CUSTOMIZATIONS_PILL_ID } from './sessionCustomizations.js'; +import { localize } from '../../../../nls.js'; +import { getChatPillEntries, ChatPillsWidget, IChatPill, IChatPillsModel, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; +import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/common/changes.js'; +import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; +import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility, type ISessionChatPillMenuEntry } from '../common/sessionChatPills.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IChat, isActiveSessionStatus } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; -import { SessionBrowsersControl } from './sessionBrowsersControl.js'; +import { SessionBackgroundActivitiesControl, sessionSubagentsPillOptions } from './sessionBackgroundActivitiesControl.js'; +import { SessionBrowsersControl, sessionBrowsersPillOptions } from './sessionBrowsersControl.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { SessionMetadataPills } from './sessionMetadataPills.js'; +import { SessionActivatingActionRunner } from '../../../browser/sessionActionRunner.js'; import './media/sessionChatInputToolbar.css'; -/** The per-turn data both pills reflect. */ -interface ITurnData { - readonly stats: IDiffStats; - /** Previewable files changed in the turn, primary (first) first. */ - readonly previewFiles: readonly IPreviewFile[]; -} - -const EMPTY_TURN_DATA: ITurnData = { stats: EMPTY_DIFF_STATS, previewFiles: [] }; - -/** - * Compute the current turn's diff stats and previewable files from the chat's - * last-turn changes ({@link IChat.lastTurnChanges}), which the provider derives - * from the live output stream. Files are classified as created vs. edited with - * the same rules as the Changes view (an addition has no original; a deletion - * has no modified resource). Created files are listed before edited ones so the - * primary (first) file is the first created one, falling back to the first - * edited one. Returns {@link EMPTY_TURN_DATA} when the chat exposes no last-turn - * changes (e.g. before its first turn, or a provider that can't determine them). - */ -function computeTurnData(chat: IChat, reader: IReader): ITurnData { - const changes = chat.lastTurnChanges?.read(reader) ?? []; - +/** Diff stats for the current turn, from the chat''s last-turn changes. */ +function computeTurnStats(chat: IChat, reader: IReader): IDiffStats { let files = 0, insertions = 0, deletions = 0; - const created: IPreviewFile[] = []; - const edited: IPreviewFile[] = []; - for (const change of changes) { - if (!change.isOutsideWorkspace) { - files++; - insertions += change.insertions; - deletions += change.deletions; + for (const change of chat.lastTurnChanges?.read(reader) ?? []) { + if (change.isOutsideWorkspace) { continue; } - - if (change.modifiedUri === undefined) { - continue; // a deletion has nothing to preview - } - const uri = isIChatSessionFileChange2(change) ? change.uri : change.modifiedUri; - const kind = previewKind(uri); - if (!kind) { - continue; - } - const isCreated = change.originalUri === undefined; - (isCreated ? created : edited).push({ uri, kind, created: isCreated }); + files++; + insertions += change.insertions; + deletions += change.deletions; } + return { files, insertions, deletions }; +} +/** Whether last-turn pills should remain available for the current session state. */ +export function shouldShowSessionTurnPills(hasDebugData: boolean, turnActive: boolean, showSessionMetadataInInput: boolean, turnStatusPillsEnabled: boolean): boolean { + return hasDebugData || turnStatusPillsEnabled && (turnActive || showSessionMetadataInInput); +} - return { - stats: { files, insertions, deletions }, - previewFiles: [...created, ...edited], - }; +/** Fake artifacts for the pill debug overlay. */ +function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): readonly IChatPillSection[] { + const entries = debugData.markdownFiles.map(name => ({ + id: name, + label: name, + resource: URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` }), + open: () => { }, + })); + return entries.length ? [{ title: localize('sessionArtifacts.files', "Files"), entries }] : []; } -function turnDataEqual(a: ITurnData, b: ITurnData): boolean { - return diffStatsEqual(a.stats, b.stats) && previewFilesEqual(a.previewFiles, b.previewFiles); +/** Action ids of the pills the sessions toolbar hosts itself. */ +export const SESSION_BROWSERS_PILL_ID = 'sessions.chatPills.browsers'; +export const SESSION_SUBAGENTS_PILL_ID = 'sessions.chatPills.subagents'; + +/** The pill kind a contributed or turn-status action belongs to, if any. */ +export function getSessionChatPillKindForAction(actionId: string): SessionChatPillKind | undefined { + switch (actionId) { + case CHAT_TURN_CHANGES_PILL_ID: + case VIEW_SESSION_CHANGES_COMMAND_ID: + return SessionChatPillKind.Changes; + case CHAT_TURN_ARTIFACT_PILL_ID: + return SessionChatPillKind.Artifacts; + case SESSION_CUSTOMIZATIONS_PILL_ID: + return SessionChatPillKind.Customizations; + case OPEN_PULL_REQUEST_ACTION_ID: + return SessionChatPillKind.PullRequests; + case OPEN_ISSUE_ACTION_ID: + return SessionChatPillKind.Issues; + case SESSION_BROWSERS_PILL_ID: + return SessionChatPillKind.Browsers; + case SESSION_SUBAGENTS_PILL_ID: + return SessionChatPillKind.Subagents; + default: + return undefined; + } } -/** A floating toolbar for the viewed chat's active-turn status and background activity. */ +/** A toolbar for session metadata, active-turn status, and background activity. */ export class SessionChatInputToolbar extends Disposable { readonly element: HTMLElement; + private readonly _content: HTMLElement; + private readonly _scrollable: DomScrollableElement; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ - private readonly _sessionOverride = observableValue('sessionOverride', 'unset'); + private readonly _sessionOverride = observableValue(this, 'unset'); /** The chat whose last-turn changes are reflected. */ - private readonly _chat = observableValue('chat', undefined); + private readonly _chat = observableValue(this, undefined); private readonly _debugData = observableValue(this, undefined); private readonly _browsers: SessionBrowsersControl; private readonly _backgroundActivities: SessionBackgroundActivitiesControl; @@ -104,10 +123,12 @@ export class SessionChatInputToolbar extends Disposable { return this._findOwningSession(chat.resource, reader); }); - /** The current turn's diff stats and previewable files. */ - private readonly _turnData: IObservable; + /** The current turn's diff stats. */ private readonly _diffStats: IObservable; - private readonly _previewFiles: IObservable; + /** Artifact sections shown in the artifact pill. */ + private readonly _artifactSections: IObservable; + /** Customization sections shown in the customizations pill. */ + private readonly _customizationSections: IObservable; /** Whether pills may show at all: an agent host session with an active turn. */ private readonly _active = derived(reader => { @@ -121,55 +142,196 @@ export class SessionChatInputToolbar extends Disposable { constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, - @IOpenerService private readonly _openerService: IOpenerService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, @ISessionsService private readonly _sessionsService: ISessionsService, @IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this.element = $('.session-chat-input-toolbar.hidden'); + this._content = $('.session-chat-input-toolbar-content'); + this._scrollable = this._register(new DomScrollableElement(this._content, { + horizontal: ScrollbarVisibility.Auto, + horizontalScrollbarSize: 6, + scrollYToX: true, + vertical: ScrollbarVisibility.Hidden, + })); + this.element = this._scrollable.getDomNode(); + this.element.classList.add('session-chat-input-toolbar', 'hidden'); - this._turnData = derivedOpts({ owner: this, equalsFn: turnDataEqual }, reader => { + this._diffStats = derivedOpts({ owner: this, equalsFn: diffStatsEqual }, reader => { const debugData = this._debugData.read(reader); if (debugData) { - return { - stats: debugData.stats, - previewFiles: debugData.markdownFiles.map(name => ({ - uri: URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` }), - kind: 'markdown', - created: true, - })), - }; + return debugData.stats; } const chat = this._chat.read(reader); - return chat ? computeTurnData(chat, reader) : EMPTY_TURN_DATA; + return chat ? computeTurnStats(chat, reader) : EMPTY_DIFF_STATS; }); - this._diffStats = derivedOpts({ owner: this, equalsFn: diffStatsEqual }, reader => this._turnData.read(reader).stats); - this._previewFiles = derivedOpts({ owner: this, equalsFn: previewFilesEqual }, reader => this._turnData.read(reader).previewFiles); + + const sessionArtifacts = this._register(instantiationService.createInstance(SessionArtifacts, this._session)); + this._artifactSections = derived(this, reader => { + const debugData = this._debugData.read(reader); + return debugData ? buildDebugArtifactSections(debugData) : sessionArtifacts.sections.read(reader); + }); + const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat)); + this._customizationSections = sessionCustomizations.sections; const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); + const showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, this._configurationService); + const showTurnPills = derived(reader => shouldShowSessionTurnPills( + this._debugData.read(reader) !== undefined, + this._active.read(reader), + showMetadataInChatInput.read(reader), + turnStatusPillsEnabled.read(reader), + )); const model: IChatTurnPillsModel = { stats: this._diffStats, - previewFiles: this._previewFiles, - changesEnabled: derived(reader => this._debugData.read(reader) !== undefined || this._active.read(reader) && turnStatusPillsEnabled.read(reader)), - previewEnabled: derived(reader => this._debugData.read(reader) !== undefined || this._active.read(reader) && turnStatusPillsEnabled.read(reader)), + artifacts: this._artifactSections, + changesEnabled: showTurnPills, + // Artifacts outlive the turn that produced them, so they only need the pills enabled. + artifactsEnabled: derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)), openChanges: () => this._debugData.get() ? undefined : this._openChanges(), - openFile: file => this._debugData.get() ? undefined : openChatTurnFile(file, this._openerService, this._configurationService), }; - const pills = this._register(instantiationService.createInstance(ChatTurnPillsWidget, model)); - this.element.appendChild(pills.element); + const turnPills = this._register(instantiationService.createInstance(ChatTurnPillsProvider, model)); + const metadataPills = this._register(instantiationService.createInstance(SessionMetadataPills, this.element, this._session, showMetadataInChatInput)); + const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility)); + + // Every pill the session currently has data for, before the user's + // per-kind visibility choices are applied. + const candidatePills = derived(reader => { + const turn = turnPills.pills.read(reader); + if (!showMetadataInChatInput.read(reader)) { + return turn; + } + return [ + ...metadataPills.pills.read(reader), + ...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID), + ]; + }); + this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); + this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Subagents, reader)))); + + // `show-file-icons` lets a resource pill paint its themed file icon. + const resourceLabels = this._register(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const sectionPill = (id: string, label: string, sections: IObservable, options: IChatDropdownPillOptions) => { + const action = this._register(new Action(id, label)); + return createChatSectionPill(action, sections, options, resourceLabels, instantiationService); + }; + + // Customization sections are not gated at the source, so gate them here the + // way the two activity controls gate their own. Data presence follows the + // feature gate but not the user's visibility choice, otherwise hiding the + // pill would drop it from the menu that restores it. + const availableCustomizations = derived(reader => turnStatusPillsEnabled.read(reader) ? this._customizationSections.read(reader) : []); + const hasCustomizations = derived(reader => getChatPillEntries(availableCustomizations.read(reader)).length > 0); + const customizationSections = derived(reader => visibility.isVisible(SessionChatPillKind.Customizations, reader) + ? availableCustomizations.read(reader) + : []); + + // Every section-backed pill lives in the same toolbar, so the whole row is + // one tab stop with arrow-key navigation instead of one stop per pill. + const sectionPills: readonly { readonly pill: IObservable; readonly sections: IObservable }[] = [ + { pill: sectionPill(SESSION_CUSTOMIZATIONS_PILL_ID, localize('sessionChatPills.customizations', "Customizations"), customizationSections, chatCustomizationPillOptions), sections: customizationSections }, + { pill: sectionPill(SESSION_BROWSERS_PILL_ID, localize('sessionChatPills.browsers', "Browsers"), this._browsers.sections, sessionBrowsersPillOptions), sections: this._browsers.sections }, + { pill: sectionPill(SESSION_SUBAGENTS_PILL_ID, localize('sessionChatPills.subagents', "Subagents"), this._backgroundActivities.sections, sessionSubagentsPillOptions), sections: this._backgroundActivities.sections }, + ]; + + const pillsModel: IChatPillsModel = { + pills: derived(reader => [ + ...candidatePills.read(reader).filter(pill => { + const kind = getSessionChatPillKindForAction(pill.action.id); + return !kind || visibility.isVisible(kind, reader); + }), + ...sectionPills + .filter(entry => getChatPillEntries(entry.sections.read(reader)).length > 0) + .map(entry => entry.pill.read(reader)), + ]), + context: this._session, + }; + const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); + const pills = this._register(instantiationService.createInstance(ChatPillsWidget, pillsModel, { + actionRunner, + // The row's visibility menu must be reachable by right-clicking a pill, + // not just the empty space beside it. + allowContextMenu: true, + })); + pills.element.classList.add('show-file-icons'); + this._content.appendChild(pills.element); + + // Kinds the session reports data for; the others cannot be toggled. + const kindsWithData = derived(reader => { + const kinds = new Set(); + for (const pill of candidatePills.read(reader)) { + const kind = getSessionChatPillKindForAction(pill.action.id); + if (kind) { + kinds.add(kind); + } + } + if (this._browsers.hasData.read(reader)) { + kinds.add(SessionChatPillKind.Browsers); + } + if (this._backgroundActivities.hasData.read(reader)) { + kinds.add(SessionChatPillKind.Subagents); + } + if (hasCustomizations.read(reader)) { + kinds.add(SessionChatPillKind.Customizations); + } + return kinds; + }); + this._register(addDisposableListener(this._content, EventType.CONTEXT_MENU, (e: MouseEvent) => { + // The row owns its context menu, so never fall through to a native one. + e.preventDefault(); + e.stopPropagation(); - this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled)); - this.element.appendChild(this._browsers.element); + const kinds = kindsWithData.get(); + if (kinds.size === 0) { + return; + } + + const anchor = new StandardMouseEvent(getWindow(this._content), e); + const targetPill = pills.getPill(e.target as HTMLElement | null); + const targetKind = targetPill ? getSessionChatPillKindForAction(targetPill.action.id) : undefined; + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => { + const menu = getSessionChatPillMenu(kinds, visibility.readHiddenKinds(undefined), targetKind); + const toggleAction = (entry: ISessionChatPillMenuEntry) => toAction({ + id: `sessions.chatPills.toggle.${entry.kind}`, + label: entry.label, + checked: entry.checked, + enabled: entry.enabled, + run: () => visibility.toggle(entry.kind), + }); + + const groups: IAction[][] = []; + if (menu.hide) { + const hide = menu.hide; + groups.push([toAction({ + id: `sessions.chatPills.hide.${hide.kind}`, + label: hide.label, + run: () => visibility.hide(hide.kind), + })]); + } + groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); + return Separator.join(...groups); + }, + }); + })); - this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled)); - this.element.appendChild(this._backgroundActivities.element); + const resizeObserver = this._register(new DisposableResizeObserver('SessionChatInputToolbar.content', () => this._scrollable.scanDomNode())); + this._register(resizeObserver.observe(this._content)); + this._register(resizeObserver.observe(pills.element)); + this._register(addDisposableListener(this._content, EventType.FOCUS_IN, () => this._scrollable.scanDomNode())); this._register(autorun(reader => { - const anyVisible = pills.isVisible.read(reader) || this._browsers.isVisible.read(reader) || this._backgroundActivities.isVisible.read(reader); - this.element.classList.toggle('hidden', !anyVisible); + const anyVisible = pills.isVisible.read(reader); + // Keep the (empty) row present while hidden pills have data so its + // context menu stays reachable and they can be shown again. + const anyHidden = kindsWithData.read(reader).size > 0; + this.element.classList.toggle('hidden', !anyVisible && !anyHidden); + this.element.classList.toggle('empty', !anyVisible); + this._scrollable.scanDomNode(); })); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts index 8aa7028cbc2d98..86cdb7312323de 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts @@ -209,7 +209,7 @@ class SessionChatPillsDebugService extends Disposable implements ISessionChatPil disposables.add(autoIncrementCheckbox.onChange(() => state.autoIncrementChanges = autoIncrementCheckbox.checked)); disposables.add(DOM.addDisposableListener(autoIncrementLabelElement, DOM.EventType.CLICK, () => setAutoIncrement(!autoIncrementCheckbox.checked))); - this._createInput(form, disposables, localize('sessions.debug.chatPills.markdownFiles', "Markdown File Names"), state.markdownFiles, value => state.markdownFiles = value); + this._createInput(form, disposables, localize('sessions.debug.chatPills.artifactFiles', "Artifact File Names"), state.markdownFiles, value => state.markdownFiles = value); this._createInput(form, disposables, localize('sessions.debug.chatPills.subagents', "Subagent Names"), state.subagents, value => state.subagents = value); this._createInput(form, disposables, localize('sessions.debug.chatPills.browsers', "Browser Labels"), state.browsers, value => state.browsers = value); diff --git a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts new file mode 100644 index 00000000000000..5d995534e71557 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { ISessionChatCustomization, SessionCustomizationKind, type IChat } from '../../../services/sessions/common/session.js'; + +/** Action id of the customizations pill. */ +export const SESSION_CUSTOMIZATIONS_PILL_ID = 'sessions.chatPills.customizations'; + +/** Presentation of the customizations pill. */ +export const chatCustomizationPillOptions: IChatDropdownPillOptions = { + widgetId: 'chatCustomizations', + icon: Codicon.bookmark, + title: localize('chatCustomizations.title', "Customizations"), + summaryLabel: count => count === 1 + ? localize('chatCustomizations.countSingle', "1 Customization") + : localize('chatCustomizations.count', "{0} Customizations", count), + summaryAriaLabel: count => count === 1 + ? localize('chatCustomizations.showSingle', "Show 1 customization") + : localize('chatCustomizations.show', "Show {0} customizations", count), + alwaysSummarize: true, +}; + +const customizationIcons: ReadonlyMap = new Map([ + [SessionCustomizationKind.Agent, Codicon.robot], + [SessionCustomizationKind.Skill, Codicon.lightbulb], + [SessionCustomizationKind.Instruction, Codicon.book], + [SessionCustomizationKind.Hook, Codicon.plug], + [SessionCustomizationKind.Prompt, Codicon.commentDiscussion], + [SessionCustomizationKind.McpServer, Codicon.mcp], + [SessionCustomizationKind.Plugin, Codicon.extensions], +]); + +/** The customizations editor section each customization kind is revealed in. */ +const customizationSections: ReadonlyMap = new Map([ + [SessionCustomizationKind.Agent, AICustomizationManagementSection.Agents], + [SessionCustomizationKind.Skill, AICustomizationManagementSection.Skills], + [SessionCustomizationKind.Instruction, AICustomizationManagementSection.Instructions], + [SessionCustomizationKind.Hook, AICustomizationManagementSection.Hooks], + [SessionCustomizationKind.Prompt, AICustomizationManagementSection.Prompts], + [SessionCustomizationKind.McpServer, AICustomizationManagementSection.McpServers], + [SessionCustomizationKind.Plugin, AICustomizationManagementSection.Plugins], +]); + +/** Section order and titles for the customizations dropdown. */ +const sectionOrder: readonly { readonly kind: SessionCustomizationKind; readonly title: string }[] = [ + { kind: SessionCustomizationKind.Agent, title: localize('sessionCustomizations.agents', "Agents") }, + { kind: SessionCustomizationKind.Skill, title: localize('sessionCustomizations.skills', "Skills") }, + { kind: SessionCustomizationKind.Instruction, title: localize('sessionCustomizations.instructions', "Instructions") }, + { kind: SessionCustomizationKind.Hook, title: localize('sessionCustomizations.hooks', "Hooks") }, + { kind: SessionCustomizationKind.Prompt, title: localize('sessionCustomizations.prompts', "Prompts") }, + { kind: SessionCustomizationKind.McpServer, title: localize('sessionCustomizations.mcpServers', "MCP Servers") }, + { kind: SessionCustomizationKind.Plugin, title: localize('sessionCustomizations.plugins', "Plugins") }, +]; + +/** Builds the dropdown sections, preserving the order customizations appeared in. */ +export function buildSessionCustomizationSections( + customizations: readonly ISessionChatCustomization[], + reveal: (customization: ISessionChatCustomization) => void, +): readonly IChatPillSection[] { + const entriesByKind = new Map(); + for (const customization of customizations) { + const entries = entriesByKind.get(customization.kind) ?? []; + entries.push({ + id: customization.id, + label: customization.name, + icon: customizationIcons.get(customization.kind) ?? Codicon.bookmark, + open: () => reveal(customization), + }); + entriesByKind.set(customization.kind, entries); + } + + const sections: IChatPillSection[] = []; + for (const { kind, title } of sectionOrder) { + const entries = entriesByKind.get(kind); + if (entries?.length) { + sections.push({ title, entries }); + } + } + return sections; +} + +/** Publishes the active chat's customization sections for the chat input pill. */ +export class SessionCustomizations extends Disposable { + readonly sections: IObservable; + + constructor( + chat: IObservable, + @ICommandService private readonly _commandService: ICommandService, + ) { + super(); + + this.sections = derivedOpts({ owner: this, equalsFn: sectionsEqual }, reader => { + const customizations = chat.read(reader)?.customizations?.read(reader) ?? []; + return buildSessionCustomizationSections(customizations, customization => this._reveal(customization)); + }); + } + + private _reveal(customization: ISessionChatCustomization): void { + void this._commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, { + section: customizationSections.get(customization.kind), + revealUri: customization.uri, + }); + } +} + +/** + * Entries are rebuilt on every recompute (their `open` closures are fresh), so + * compare the identity that actually drives rendering. + */ +function sectionsEqual(a: readonly IChatPillSection[], b: readonly IChatPillSection[]): boolean { + return a.length === b.length && a.every((section, i) => section.title === b[i].title + && section.entries.length === b[i].entries.length + && section.entries.every((entry, j) => entry.id === b[i].entries[j].id && entry.label === b[i].entries[j].label)); +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts new file mode 100644 index 00000000000000..ed3b9428d53fad --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow } from '../../../../base/browser/dom.js'; +import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { Event } from '../../../../base/common/event.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenuService, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { ChatPillActionViewItem, IChatPill } from '../../../../workbench/browser/chatPills.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { setSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; + +/** Adapts the session metadata menu to observable chat-pill descriptors. */ +export class SessionMetadataPills extends Disposable { + + readonly pills: IObservable; + + private readonly _scopedInstantiationService: IInstantiationService; + + constructor( + container: HTMLElement, + session: IObservable, + enabled: IObservable, + @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + @IMenuService menuService: IMenuService, + ) { + super(); + + const scopedContextKeyService = this._register(contextKeyService.createScoped(container)); + this._scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection( + [IContextKeyService, scopedContextKeyService], + [ISessionContext, new SessionContext(session)], + ))); + + this._register(autorun(reader => { + setSessionContextKeys(session.read(reader), scopedContextKeyService, reader); + })); + + const menu = this._register(menuService.createMenu(Menus.SessionHeaderMeta, scopedContextKeyService, { emitEventsForSubmenuChanges: true })); + const menuSignal = observableSignalFromEvent(this, Event.any( + menu.onDidChange, + Event.filter(this._actionViewItemService.onDidChange, menuId => menuId === Menus.SessionHeaderMeta), + )); + this.pills = derived(this, reader => { + menuSignal.read(reader); + if (!enabled.read(reader)) { + return []; + } + + return menu.getActions({ shouldForwardArgs: true }).flatMap(([group, actions]) => { + if (group !== 'navigation') { + return []; + } + return actions.map(action => ({ + action, + createActionViewItem: (options: IActionViewItemOptions) => { + const provider = this._actionViewItemService.lookUp( + Menus.SessionHeaderMeta, + action instanceof SubmenuItemAction ? action.item.submenu.id : action.id, + ); + return provider?.(action, options, this._scopedInstantiationService, getWindow(container).vscodeWindowId) + ?? this._scopedInstantiationService.createInstance(ChatPillActionViewItem, undefined, action, options); + }, + } satisfies IChatPill)); + }); + }); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 010395b285fc4a..b7f588f023ed3e 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -32,6 +32,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat const content: string[] = []; content.push(localize('sessionsChat.overview', "You are in the Agents window. The Agents window is a dedicated workspace for working with AI agents. It provides a chat interface, a changes view for reviewing agent-generated changes, a file explorer, and customization options.")); content.push(localize('sessionsChat.input', "You are in the chat input. Type a message and press Enter to send it.")); + content.push(localize('sessionsChat.inputPills', "When session metadata or active-turn status pills appear above the input, press Tab to reach them, use the Left and Right arrow keys to move between them, and press Enter or Space to activate one. Right-click a pill to choose which pills are shown.")); content.push(localize('sessionsChat.externalSessionFilter', "The Sessions list Filter menu includes an External submenu. Use it to choose whether external sessions from another application are shown for the last 24 hours, the last 7 days, always, or not at all.")); content.push(localize('sessionsChat.externalSessionBanner', "When you first open a session created in another application, a banner appears at the top of the chat. Use Tab to reach its external-session picker, choose an option, and activate Save. The Close action dismisses the banner without changing the setting. Saving or closing permanently dismisses the banner.")); content.push(localize('sessionsChat.promptOptions', "When prompt options appear above the new-session input, use Tab and Shift+Tab to move between them, then press Enter or Space to insert one. You can select a different option while the input is empty, exactly matches the inserted prompt, or only has its editable placeholder removed; other edits disable the options without hiding them. Clearing the input also clears the selected option. Use the Close action to hide the options and return focus to the input.")); diff --git a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts new file mode 100644 index 00000000000000..d44449367ad6a2 --- /dev/null +++ b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IReader } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { observableMemento, ObservableMemento } from '../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +/** The kinds of pill shown above the chat input, each independently hideable. */ +export const enum SessionChatPillKind { + Changes = 'changes', + Artifacts = 'artifacts', + Customizations = 'customizations', + PullRequests = 'pullRequests', + Issues = 'issues', + Browsers = 'browsers', + Subagents = 'subagents', +} + +/** All pill kinds, in the order they are offered in the visibility menu. */ +export const SESSION_CHAT_PILL_KINDS: readonly SessionChatPillKind[] = [ + SessionChatPillKind.Changes, + SessionChatPillKind.Artifacts, + SessionChatPillKind.Customizations, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Browsers, + SessionChatPillKind.Subagents, +]; + +export function getSessionChatPillLabel(kind: SessionChatPillKind): string { + switch (kind) { + case SessionChatPillKind.Changes: return localize('sessionChatPills.changes', "Changes"); + case SessionChatPillKind.Artifacts: return localize('sessionChatPills.artifacts', "Artifacts"); + case SessionChatPillKind.Customizations: return localize('sessionChatPills.customizations', "Customizations"); + case SessionChatPillKind.PullRequests: return localize('sessionChatPills.pullRequests', "Pull Requests"); + case SessionChatPillKind.Issues: return localize('sessionChatPills.issues', "Issues"); + case SessionChatPillKind.Browsers: return localize('sessionChatPills.browsers', "Browsers"); + case SessionChatPillKind.Subagents: return localize('sessionChatPills.subagents', "Subagents"); + } +} + +/** + * Whether the user can hide a pill. Changes reports what the turn did to the + * user's files, so it always shows once it has data. + */ +export function isSessionChatPillHideable(kind: SessionChatPillKind): boolean { + return kind !== SessionChatPillKind.Changes; +} + +/** One entry of the pill visibility context menu. */ +export interface ISessionChatPillMenuEntry { + readonly kind: SessionChatPillKind; + readonly label: string; + /** Whether the pill shows when it has data. */ + readonly checked: boolean; + /** Kinds without data cannot be toggled. */ + readonly enabled: boolean; +} + +/** + * The pill visibility context menu: an optional "Hide X" for the pill that was + * right-clicked, then the kinds the session has data for, then the rest. The + * caller renders a separator between the groups it shows. + */ +export interface ISessionChatPillMenu { + readonly hide?: { readonly kind: SessionChatPillKind; readonly label: string }; + readonly withData: readonly ISessionChatPillMenuEntry[]; + readonly withoutData: readonly ISessionChatPillMenuEntry[]; +} + +/** + * Builds the visibility menu. Every hideable kind is listed, checked while it is + * not hidden, and disabled while the session reports no data for it. + * + * @param targetKind The pill that was right-clicked, which gains a "Hide X" + * entry. Omitted when the click did not land on a pill. + */ +export function getSessionChatPillMenu( + kindsWithData: ReadonlySet, + hiddenKinds: ReadonlySet, + targetKind?: SessionChatPillKind, +): ISessionChatPillMenu { + const withData: ISessionChatPillMenuEntry[] = []; + const withoutData: ISessionChatPillMenuEntry[] = []; + for (const kind of SESSION_CHAT_PILL_KINDS) { + if (!isSessionChatPillHideable(kind)) { + continue; + } + const enabled = kindsWithData.has(kind); + (enabled ? withData : withoutData).push({ + kind, + label: getSessionChatPillLabel(kind), + checked: !hiddenKinds.has(kind), + enabled, + }); + } + + const hide = targetKind !== undefined && isSessionChatPillHideable(targetKind) + ? { kind: targetKind, label: localize('sessionChatPills.hide', "Hide {0}", getSessionChatPillLabel(targetKind)) } + : undefined; + + return { ...(hide ? { hide } : {}), withData, withoutData }; +} + +/** + * Pills hidden until the user turns them on: useful but noisy enough that they + * should not claim room in the row by default. + */ +const defaultHiddenKinds: readonly SessionChatPillKind[] = [ + SessionChatPillKind.Customizations, + SessionChatPillKind.Subagents, +]; + +const hiddenSessionChatPills = observableMemento({ + defaultValue: defaultHiddenKinds, + key: 'sessions.chatPills.hidden', + toStorage: kinds => JSON.stringify(kinds), + fromStorage: value => { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((kind): kind is string => typeof kind === 'string') : []; + }, +}); + +/** The user's per-kind pill visibility choices, persisted across windows. */ +export class SessionChatPillVisibility extends Disposable { + + private readonly _hiddenKinds: ObservableMemento; + + constructor( + @IStorageService storageService: IStorageService, + ) { + super(); + this._hiddenKinds = this._register(hiddenSessionChatPills(StorageScope.APPLICATION, StorageTarget.USER, storageService)); + } + + readHiddenKinds(reader: IReader | undefined): ReadonlySet { + return new Set((this._hiddenKinds.read(reader) as readonly SessionChatPillKind[]).filter(isSessionChatPillHideable)); + } + + isVisible(kind: SessionChatPillKind, reader: IReader | undefined): boolean { + return !isSessionChatPillHideable(kind) || !this._hiddenKinds.read(reader).includes(kind); + } + + hide(kind: SessionChatPillKind): void { + if (isSessionChatPillHideable(kind) && !this._hiddenKinds.get().includes(kind)) { + this._hiddenKinds.set([...this._hiddenKinds.get(), kind], undefined); + } + } + + toggle(kind: SessionChatPillKind): void { + if (!isSessionChatPillHideable(kind)) { + return; + } + const hidden = this._hiddenKinds.get(); + this._hiddenKinds.set(hidden.includes(kind) ? hidden.filter(hiddenKind => hiddenKind !== kind) : [...hidden, kind], undefined); + } +} diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts index 1dcdbde8785555..d7dfa6e28445c4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts @@ -4,13 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -21,21 +18,15 @@ interface IControlSpec { readonly subagents?: readonly string[]; readonly subagentStatus?: SessionStatus; readonly enabled?: boolean; + /** Whether the user keeps the subagents pill visible. */ + readonly visible?: boolean; } interface IControlHarness { readonly control: SessionBackgroundActivitiesControl; - readonly getPickerItems: () => readonly ICapturedPickerItem[]; readonly getOpenedChat: () => URI | undefined; } -interface ICapturedPickerItem { - readonly kind: ActionListItemKind; - readonly label: string; - readonly category: string; - readonly icon: string; -} - function createControl(spec: IControlSpec, store: ReturnType): IControlHarness { const mainChat = new class extends mock() { override readonly resource = URI.parse('chat:main'); @@ -53,20 +44,6 @@ function createControl(spec: IControlSpec, store: ReturnType() { - override get isVisible() { return false; } - override hide(): void { } - override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], _delegate: IActionListDelegate): void { - pickerItems = items.map(item => ({ - kind: item.kind, - label: item.label ?? '', - category: item.group?.title ?? '', - icon: item.group?.icon?.id ?? '', - })); - } - }(); - let openedChat: URI | undefined; const sessionsService = new class extends mock() { override async openChat(_session: ISession, chatUri: URI): Promise { @@ -78,32 +55,20 @@ function createControl(spec: IControlSpec, store: ReturnType pickerItems, - getOpenedChat: () => openedChat, - }; + return { control, getOpenedChat: () => openedChat }; } -function summarize(control: SessionBackgroundActivitiesControl): { readonly text: string; readonly ariaLabel: string | null; readonly icons: readonly string[] } { - const button = control.element.querySelector('.session-activity-pill-button')!; - const knownIcons = [Codicon.globe, Codicon.agent, Codicon.sessionInProgress, Codicon.chevronDown]; - return { - text: button.textContent ?? '', - ariaLabel: button.getAttribute('aria-label'), - icons: [...button.querySelectorAll('.codicon')] - .map(element => knownIcons.find(icon => element.classList.contains(`codicon-${icon.id}`))?.id ?? 'unknown'), - }; +/** The sections the control publishes, reduced to what the pill renders from. */ +function sections(control: SessionBackgroundActivitiesControl): readonly { readonly title: string; readonly entries: readonly { readonly label: string; readonly icon: string }[] }[] { + return control.sections.get().map(section => ({ + title: section.title, + entries: section.entries.map(entry => ({ label: entry.label, icon: entry.icon?.id ?? '' })), + })); } - -function click(control: SessionBackgroundActivitiesControl): void { - control.element.querySelector('.session-activity-pill-button')!.click(); -} - suite('SessionBackgroundActivitiesControl', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -139,36 +104,43 @@ suite('SessionBackgroundActivitiesControl', () => { }); }); - test('renders single and aggregate labels, icons, and subagent truncation', () => { + test('publishes running subagents as one section, truncating long labels', () => { const cases: IControlSpec[] = [ { subagents: ['Research'] }, { subagents: ['Investigate the authentication failure in production'] }, { subagents: ['Research', 'Review'] }, ]; - const disabled = createControl({ subagents: ['Research'], enabled: false }, store); assert.deepStrictEqual({ - enabled: cases.map(spec => summarize(createControl(spec, store).control)), - disabledVisible: disabled.control.isVisible.get(), + sections: cases.map(spec => sections(createControl(spec, store).control)), + disabled: sections(createControl({ subagents: ['Research'], enabled: false }, store).control), }, { - enabled: [ - { text: 'Research', ariaLabel: 'Open Research', icons: ['agent'] }, - { text: 'Investigate the authentication...', ariaLabel: 'Open Investigate the authentication...', icons: ['agent'] }, - { text: '2 Active Subagents', ariaLabel: 'Show 2 background activities', icons: ['agent', 'chevron-down'] }, + sections: [ + [{ title: 'Subagents', entries: [{ label: 'Research', icon: 'agent' }] }], + [{ title: 'Subagents', entries: [{ label: 'Investigate the authentication...', icon: 'agent' }] }], + [{ title: 'Subagents', entries: [{ label: 'Research', icon: 'agent' }, { label: 'Review', icon: 'agent' }] }], ], - disabledVisible: false, + disabled: [], }); }); - test('keeps subagents visible while they need input', () => { + test('keeps subagents listed while they need input', () => { const harness = createControl({ subagents: ['Waiting'], subagentStatus: SessionStatus.NeedsInput }, store); + assert.deepStrictEqual(sections(harness.control), [ + { title: 'Subagents', entries: [{ label: 'Waiting', icon: 'agent' }] }, + ]); + }); + + test('still reports data while the user hides the pill, so it can be shown again', () => { + const harness = createControl({ subagents: ['Research'], visible: false }, store); + assert.deepStrictEqual({ - visible: harness.control.isVisible.get(), - summary: summarize(harness.control), + sections: sections(harness.control), + hasData: harness.control.hasData.get(), }, { - visible: true, - summary: { text: 'Waiting', ariaLabel: 'Open Waiting', icons: ['agent'] }, + sections: [], + hasData: true, }); }); @@ -185,31 +157,19 @@ suite('SessionBackgroundActivitiesControl', () => { agentFeedback: 4, autoIncrementChanges: false, }); - const forced = summarize(harness.control); + const forced = sections(harness.control); harness.control.setDebugData(undefined); - assert.deepStrictEqual({ forced, visibleAfterClear: harness.control.isVisible.get() }, { - forced: { text: 'Debug Subagent', ariaLabel: 'Open Debug Subagent', icons: ['agent'] }, - visibleAfterClear: false, + assert.deepStrictEqual({ forced, afterClear: sections(harness.control) }, { + forced: [{ title: 'Subagents', entries: [{ label: 'Debug Subagent', icon: 'agent' }] }], + afterClear: [], }); }); - test('lists subagents in a picker under a category header', () => { - const harness = createControl({ subagents: ['Research', 'Review'] }, store); - - click(harness.control); - - assert.deepStrictEqual(harness.getPickerItems(), [ - { kind: ActionListItemKind.Header, label: 'Subagents', category: 'Subagents', icon: '' }, - { kind: ActionListItemKind.Action, label: 'Research', category: '', icon: Codicon.agent.id }, - { kind: ActionListItemKind.Action, label: 'Review', category: '', icon: Codicon.agent.id }, - ]); - }); - - test('opens a single subagent directly', () => { + test('opening an entry opens that subagent chat', () => { const harness = createControl({ subagents: ['Research'] }, store); - click(harness.control); + harness.control.sections.get()[0].entries[0].open(); assert.deepStrictEqual(harness.getOpenedChat()?.toString(), 'chat:subagent-0'); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts index dbc12585cfb303..b8b03fa95f07ba 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts @@ -4,14 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { Event } from '../../../../../base/common/event.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { BrowserViewSharingState, IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; @@ -27,27 +24,19 @@ interface IControlSpec { readonly sharingState?: BrowserViewSharingState; }[]; readonly enabled?: boolean; + /** Whether the user keeps the browsers pill visible. */ + readonly visible?: boolean; /** Start with only the main chat, so the subagent can be added later. */ readonly withoutSubagent?: boolean; } interface IControlHarness { readonly control: SessionBrowsersControl; - readonly getPickerItems: () => readonly ICapturedPickerItem[]; - readonly selectPickerItem: (label: string) => void; readonly getBrowserOpenCount: () => number; readonly getOpenedBrowserId: () => string | undefined; readonly addSubagent: () => void; } -interface ICapturedPickerItem { - readonly kind: ActionListItemKind; - readonly label: string; - readonly category: string; - readonly icon: string; - readonly select?: () => void; -} - function createControl(spec: IControlSpec, store: ReturnType): IControlHarness { const mainChat = new class extends mock() { override readonly resource = URI.parse('chat:main'); @@ -90,31 +79,6 @@ function createControl(spec: IControlSpec, store: ReturnType() { - override get isVisible() { return false; } - override hide(): void { } - override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { - pickerItems = items.map(item => { - const value = item.item; - return { - kind: item.kind, - label: item.label ?? '', - category: item.group?.title ?? '', - icon: item.group?.icon?.id ?? '', - select: value === undefined ? undefined : () => delegate.onSelect(value), - }; - }); - } - }(); - const selectPickerItem = (label: string) => { - const item = pickerItems.find(item => item.label === label && item.select); - if (!item?.select) { - throw new Error(`Picker item '${label}' not found`); - } - item.select(); - }; - let browserOpenCount = 0; let openedBrowserId: string | undefined; const browserIds = new Map(inputs.map(input => [input, input.id])); @@ -131,58 +95,58 @@ function createControl(spec: IControlSpec, store: ReturnType pickerItems, - selectPickerItem, getBrowserOpenCount: () => browserOpenCount, getOpenedBrowserId: () => openedBrowserId, addSubagent: () => chats.set([mainChat, subagent], undefined), }; } -function summarize(control: SessionBrowsersControl): { readonly text: string; readonly ariaLabel: string | null; readonly icons: readonly string[] } { - const button = control.element.querySelector('.session-activity-pill-button')!; - const knownIcons = [Codicon.globe, Codicon.agent, Codicon.sessionInProgress, Codicon.chevronDown]; - return { - text: button.textContent ?? '', - ariaLabel: button.getAttribute('aria-label'), - icons: [...button.querySelectorAll('.codicon')] - .map(element => knownIcons.find(icon => element.classList.contains(`codicon-${icon.id}`))?.id ?? 'unknown'), - }; +/** The sections the control publishes, reduced to what the pill renders from. */ +function sections(control: SessionBrowsersControl): readonly { readonly title: string; readonly entries: readonly { readonly label: string; readonly icon: string }[] }[] { + return control.sections.get().map(section => ({ + title: section.title, + entries: section.entries.map(entry => ({ label: entry.label, icon: entry.icon?.id ?? '' })), + })); } -function click(control: SessionBrowsersControl): void { - control.element.querySelector('.session-activity-pill-button')!.click(); +/** Opens an entry, as the pill does on click or on selecting a dropdown row. */ +function openEntry(control: SessionBrowsersControl, label?: string): void { + const entries = control.sections.get().flatMap(section => section.entries); + const entry = label ? entries.find(candidate => candidate.label === label) : entries[0]; + if (!entry) { + throw new Error(`Browser entry '${label ?? ''}' not found`); + } + entry.open(); } suite('SessionBrowsersControl', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('renders single and aggregate labels, icons, and fallback', () => { + test('publishes browser entries with a fallback label', () => { const cases: IControlSpec[] = [ { browsers: [{ title: 'Visual Studio Code' }] }, { browsers: [{}] }, { browsers: [{ title: 'Docs' }, { title: 'Preview' }] }, ]; - const disabled = createControl({ browsers: [{ title: 'Hidden browser' }], enabled: false }, store); assert.deepStrictEqual({ - enabled: cases.map(spec => summarize(createControl(spec, store).control)), - disabledVisible: disabled.control.isVisible.get(), + enabled: cases.map(spec => sections(createControl(spec, store).control)), + disabled: sections(createControl({ browsers: [{ title: 'Hidden browser' }], enabled: false }, store).control), }, { enabled: [ - { text: 'Visual Studio Code', ariaLabel: 'Open Visual Studio Code', icons: ['globe'] }, - { text: 'Browser', ariaLabel: 'Open Browser', icons: ['globe'] }, - { text: '2 Active Browsers', ariaLabel: 'Show 2 browsers', icons: ['globe', 'chevron-down'] }, + [{ title: 'Browsers', entries: [{ label: 'Visual Studio Code', icon: 'globe' }] }], + [{ title: 'Browsers', entries: [{ label: 'Browser', icon: 'globe' }] }], + [{ title: 'Browsers', entries: [{ label: 'Docs', icon: 'globe' }, { label: 'Preview', icon: 'globe' }] }], ], - disabledVisible: false, + disabled: [], }); }); @@ -199,12 +163,12 @@ suite('SessionBrowsersControl', () => { agentFeedback: 4, autoIncrementChanges: false, }); - const forced = summarize(harness.control); + const forced = sections(harness.control); harness.control.setDebugData(undefined); - assert.deepStrictEqual({ forced, visibleAfterClear: harness.control.isVisible.get() }, { - forced: { text: 'Debug Browser', ariaLabel: 'Open Debug Browser', icons: ['globe'] }, - visibleAfterClear: false, + assert.deepStrictEqual({ forced, afterClear: sections(harness.control) }, { + forced: [{ title: 'Browsers', entries: [{ label: 'Debug Browser', icon: 'globe' }] }], + afterClear: [], }); }); @@ -217,37 +181,35 @@ suite('SessionBrowsersControl', () => { ], }, store); - click(harness.control); - harness.selectPickerItem('Subagent Preview'); + openEntry(harness.control, 'Subagent Preview'); await Promise.resolve(); assert.deepStrictEqual({ - items: harness.getPickerItems().map(({ select: _select, ...item }) => item), + sections: sections(harness.control), openedBrowser: harness.getOpenedBrowserId(), }, { - items: [ - { kind: ActionListItemKind.Header, label: 'Browsers', category: 'Browsers', icon: '' }, - { kind: ActionListItemKind.Action, label: 'Docs', category: '', icon: Codicon.globe.id }, - { kind: ActionListItemKind.Action, label: 'Subagent Preview', category: '', icon: Codicon.globe.id }, - ], + sections: [{ + title: 'Browsers', + entries: [{ label: 'Docs', icon: 'globe' }, { label: 'Subagent Preview', icon: 'globe' }], + }], openedBrowser: 'browser-1', }); }); test('shows a subagent browser registered before the subagent joins the session', () => { const harness = createControl({ browsers: [{ title: 'Subagent Preview', owner: 'subagent' }], withoutSubagent: true }, store); - const beforeJoin = harness.control.isVisible.get(); + const beforeJoin = sections(harness.control); harness.addSubagent(); - assert.deepStrictEqual({ beforeJoin, afterJoin: summarize(harness.control) }, { - beforeJoin: false, - afterJoin: { text: 'Subagent Preview', ariaLabel: 'Open Subagent Preview', icons: ['globe'] }, + assert.deepStrictEqual({ beforeJoin, afterJoin: sections(harness.control) }, { + beforeJoin: [], + afterJoin: [{ title: 'Browsers', entries: [{ label: 'Subagent Preview', icon: 'globe' }] }], }); }); test('opens a single browser directly', async () => { const harness = createControl({ browsers: [{ title: 'Preview' }] }, store); - click(harness.control); + openEntry(harness.control); await Promise.resolve(); assert.deepStrictEqual({ @@ -266,7 +228,7 @@ suite('SessionBrowsersControl', () => { { title: 'Shared Host', url: 'https://example.com/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, ], }, store); - click(sharedHost.control); + openEntry(sharedHost.control, 'Normal'); await Promise.resolve(); const sharedExact = createControl({ @@ -276,7 +238,7 @@ suite('SessionBrowsersControl', () => { { title: 'Shared Exact', url: 'https://example.com/start', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, ], }, store); - click(sharedExact.control); + openEntry(sharedExact.control, 'Normal'); await Promise.resolve(); const fallback = createControl({ @@ -285,7 +247,7 @@ suite('SessionBrowsersControl', () => { { title: 'Unrelated Shared', url: 'https://other.test/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, ], }, store); - click(fallback.control); + openEntry(fallback.control, 'Normal'); await Promise.resolve(); assert.deepStrictEqual({ diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts new file mode 100644 index 00000000000000..ccf9e8130b85d0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; +import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../github/common/types.js'; +import { SessionChatPillKind } from '../../common/sessionChatPills.js'; +import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID, shouldShowSessionTurnPills } from '../../browser/sessionChatInputToolbar.js'; +import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; + +suite('SessionChatInputToolbar', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps turn-status, contributed metadata and hosted pill actions onto togglable pill kinds', () => { + assert.deepStrictEqual([ + getSessionChatPillKindForAction(CHAT_TURN_CHANGES_PILL_ID), + getSessionChatPillKindForAction(VIEW_SESSION_CHANGES_COMMAND_ID), + getSessionChatPillKindForAction(CHAT_TURN_ARTIFACT_PILL_ID), + getSessionChatPillKindForAction(SESSION_CUSTOMIZATIONS_PILL_ID), + getSessionChatPillKindForAction(OPEN_PULL_REQUEST_ACTION_ID), + getSessionChatPillKindForAction(OPEN_ISSUE_ACTION_ID), + getSessionChatPillKindForAction(SESSION_BROWSERS_PILL_ID), + getSessionChatPillKindForAction(SESSION_SUBAGENTS_PILL_ID), + getSessionChatPillKindForAction('workbench.agentSessions.action.openFilesView'), + ], [ + SessionChatPillKind.Changes, + SessionChatPillKind.Changes, + SessionChatPillKind.Artifacts, + SessionChatPillKind.Customizations, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Browsers, + SessionChatPillKind.Subagents, + undefined, + ]); + }); + + test('keeps last-turn pills visible after completion only in metadata-input placement', () => { + assert.deepStrictEqual([ + shouldShowSessionTurnPills(false, false, false, true), + shouldShowSessionTurnPills(false, false, true, true), + shouldShowSessionTurnPills(false, true, false, true), + shouldShowSessionTurnPills(false, false, true, false), + ], [ + false, + true, + true, + false, + ]); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts new file mode 100644 index 00000000000000..30e3327e4f6b7d --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { buildSessionCustomizationSections } from '../../browser/sessionCustomizations.js'; +import { ISessionChatCustomization, SessionCustomizationKind } from '../../../../services/sessions/common/session.js'; + +suite('Session Customizations', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const customization = (id: string, kind: SessionCustomizationKind, name: string): ISessionChatCustomization => + ({ id, kind, name, uri: URI.file(`/repo/${id}.md`) }); + + test('groups into typed sections in a fixed order, keeping arrival order within a section', () => { + const sections = buildSessionCustomizationSections([ + customization('c1', SessionCustomizationKind.Hook, 'pre-commit'), + customization('c2', SessionCustomizationKind.Skill, 'sessions'), + customization('c3', SessionCustomizationKind.Instruction, 'writing-tests'), + customization('c4', SessionCustomizationKind.Skill, 'unit-tests'), + customization('c5', SessionCustomizationKind.Agent, 'rubber-duck'), + ], () => { }); + + assert.deepStrictEqual(sections.map(section => ({ title: section.title, entries: section.entries.map(entry => entry.label) })), [ + { title: 'Agents', entries: ['rubber-duck'] }, + { title: 'Skills', entries: ['sessions', 'unit-tests'] }, + { title: 'Instructions', entries: ['writing-tests'] }, + { title: 'Hooks', entries: ['pre-commit'] }, + ]); + }); + + test('activating an entry reveals its customization', () => { + const revealed: string[] = []; + const sections = buildSessionCustomizationSections( + [customization('c1', SessionCustomizationKind.Skill, 'sessions')], + target => revealed.push(target.id), + ); + sections[0].entries[0].open(); + + assert.deepStrictEqual(revealed, ['c1']); + }); + + test('no customizations yields no sections', () => { + assert.deepStrictEqual(buildSessionCustomizationSections([], () => { }), []); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts new file mode 100644 index 00000000000000..f3d44b8f17bc30 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility } from '../../common/sessionChatPills.js'; + +suite('SessionChatPills', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('groups kinds with data ahead of those without, and omits the never-hideable Changes pill', () => { + const menu = getSessionChatPillMenu( + new Set([SessionChatPillKind.Changes, SessionChatPillKind.PullRequests, SessionChatPillKind.Subagents]), + new Set([SessionChatPillKind.PullRequests]), + ); + + assert.deepStrictEqual(menu, { + withData: [ + { kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: false, enabled: true }, + { kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true, enabled: true }, + ], + withoutData: [ + { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true, enabled: false }, + { kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true, enabled: false }, + { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true, enabled: false }, + { kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true, enabled: false }, + ], + }); + }); + + test('offers Hide for the right-clicked pill, but never for Changes', () => { + const kindsWithData = new Set([SessionChatPillKind.Changes, SessionChatPillKind.Issues]); + + assert.deepStrictEqual({ + issues: getSessionChatPillMenu(kindsWithData, new Set(), SessionChatPillKind.Issues).hide, + changes: getSessionChatPillMenu(kindsWithData, new Set(), SessionChatPillKind.Changes).hide, + noTarget: getSessionChatPillMenu(kindsWithData, new Set()).hide, + }, { + issues: { kind: SessionChatPillKind.Issues, label: 'Hide Issues' }, + changes: undefined, + noTarget: undefined, + }); + }); + + test('hides customizations and subagents by default, and always shows changes', () => { + const visibility = disposables.add(new SessionChatPillVisibility(disposables.add(new TestStorageService()))); + + assert.deepStrictEqual({ + customizations: visibility.isVisible(SessionChatPillKind.Customizations, undefined), + subagents: visibility.isVisible(SessionChatPillKind.Subagents, undefined), + artifacts: visibility.isVisible(SessionChatPillKind.Artifacts, undefined), + changes: visibility.isVisible(SessionChatPillKind.Changes, undefined), + }, { + customizations: false, + subagents: false, + artifacts: true, + changes: true, + }); + }); + + test('changes cannot be hidden or toggled off', () => { + const visibility = disposables.add(new SessionChatPillVisibility(disposables.add(new TestStorageService()))); + visibility.hide(SessionChatPillKind.Changes); + visibility.toggle(SessionChatPillKind.Changes); + + assert.deepStrictEqual({ + visible: visibility.isVisible(SessionChatPillKind.Changes, undefined), + hiddenKinds: [...visibility.readHiddenKinds(undefined)], + }, { + visible: true, + hiddenKinds: [SessionChatPillKind.Customizations, SessionChatPillKind.Subagents], + }); + }); + + test('hides a pill, then toggles it off and on again, persisting the choice', () => { + const storageService = disposables.add(new TestStorageService()); + const visibility = disposables.add(new SessionChatPillVisibility(storageService)); + + const initiallyVisible = visibility.isVisible(SessionChatPillKind.PullRequests, undefined); + visibility.hide(SessionChatPillKind.PullRequests); + const afterHide = { + pullRequests: visibility.isVisible(SessionChatPillKind.PullRequests, undefined), + issues: visibility.isVisible(SessionChatPillKind.Issues, undefined), + restored: disposables.add(new SessionChatPillVisibility(storageService)).isVisible(SessionChatPillKind.PullRequests, undefined), + }; + // Hiding an already-hidden pill is a no-op, so one toggle brings it back. + visibility.hide(SessionChatPillKind.PullRequests); + visibility.toggle(SessionChatPillKind.PullRequests); + + assert.deepStrictEqual({ + initiallyVisible, + afterHide, + afterShow: visibility.isVisible(SessionChatPillKind.PullRequests, undefined), + }, { + initiallyVisible: true, + afterHide: { pullRequests: false, issues: true, restored: false }, + afterShow: true, + }); + }); +}); diff --git a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts index 5b4f7a86b07df6..5d7e4c43006667 100644 --- a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts +++ b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts @@ -23,12 +23,13 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; +import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; +import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { SessionHasWorkspaceContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { NEW_FILE_TAB_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { getSessionWorkspaceKind, SessionWorkspaceKind } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { SESSIONS_FILES_VIEW_ID } from './filesView.js'; @@ -50,7 +51,11 @@ export class OpenFilesViewAction extends Action2 { id: Menus.SessionHeaderMeta, group: 'navigation', order: -10, - when: ContextKeyExpr.and(SessionHasWorkspaceContext, IsQuickChatSessionContext.negate()) + when: ContextKeyExpr.and( + SessionHasWorkspaceContext, + IsQuickChatSessionContext.negate(), + ContextKeyExpr.notEquals(`config.${SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING}`, true), + ) }, }); } @@ -80,24 +85,15 @@ registerAction2(OpenFilesViewAction); // --- Open Files view action view item (session header workspace folder pill) -interface IWorkspaceInfo { - readonly label: string; - readonly icon: ThemeIcon; - readonly workingDirectoryPath: string | undefined; - readonly branch: string | undefined; - /** The session's worktree does not exist yet, so path and branch are unknown. */ - readonly worktreePending: boolean; -} - /** * Renders the session's workspace folder as a `