From cc96f8fbb69b9841db04a9e65d3af032ab716243 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Fri, 26 Jun 2026 18:29:39 +0200 Subject: [PATCH 01/59] agentHost: show download progress for agent SDK binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code ships the Claude and Codex agents but not their native SDK binaries; those are downloaded on demand from the CDN the first time a session of that provider materializes, then cached on disk. The tarballs are 70-95 MB, so on a cold cache the first session start blocked for several seconds with no user feedback. Surface a progress indicator instead. Wire format: a generic, operation-agnostic `root/progress` notification (mirrors the AHP spec), correlated to its originating request by a `progressToken` the client supplies on `createSession` rather than naming a domain object. Completion is signalled by `progress === total`; the host emits a terminal frame with `total === progress`. Downloader: `_fetch` accumulates received bytes and reads `Content-Length`, firing a throttled host-level `onDidDownloadProgress` event keyed by package id. Adds `isSdkResolvableWithoutDownload` / `canLoadWithoutDownload` so eager/background callers (e.g. `listSessions` at startup) never kick off a cold download. Server (agent host): `createSession` records `{provider -> {session -> token}}`; when a session materializes on first message and triggers the cold download, the host-level event is fanned out as one `root/progress` notification per waiting token (package id === provider id), building the localized `message` ("Downloading {0} agent…"). Tokens are cleared on materialize / dispose. Codex prewarm is gated so it can't trigger a cold download. Client (renderer): the new-session composer supplies a `progressToken` on its eager `createSession`; `_handleProgress` correlates incoming frames by token, shows a determinate (or byte-count) notification, and dismisses it once `progress >= total`. Vendors the protocol copy at AHP d35ed4a. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostProtocolClient.ts | 1 + .../platform/agentHost/common/agentService.ts | 7 + .../common/state/protocol/.ahp-version | 2 +- .../protocol/channels-root/notifications.ts | 79 ++++++++ .../protocol/channels-session/commands.ts | 13 ++ .../common/state/protocol/common/messages.ts | 3 +- .../common/state/protocol/version/registry.ts | 1 + .../agentHost/common/state/sessionActions.ts | 5 +- .../platform/agentHost/node/agentHostMain.ts | 27 ++- .../agentHost/node/agentHostServerMain.ts | 23 ++- .../agentHost/node/agentHostStateManager.ts | 19 +- .../agentHost/node/agentSdkDownloader.ts | 180 +++++++++++++++++- .../platform/agentHost/node/agentService.ts | 70 +++++++ .../agentHost/node/claude/claudeAgent.ts | 9 + .../node/claude/claudeAgentSdkService.ts | 22 +++ .../agentHost/node/codex/codexAgent.ts | 23 ++- .../agentHost/node/protocolServerHandler.ts | 1 + .../test/node/agentSdkDownloader.test.ts | 34 +++- .../test/node/claudeAgent.integrationTest.ts | 4 + .../agentHost/test/node/claudeAgent.test.ts | 6 + .../test/node/claudeSubagentResolver.test.ts | 1 + .../test/node/protocolServerHandler.test.ts | 66 ++++++- .../browser/baseAgentHostSessionsProvider.ts | 117 +++++++++++- .../browser/localAgentHostSessionsProvider.ts | 4 +- .../localAgentHostSessionsProvider.test.ts | 2 + .../remoteAgentHostSessionsProvider.ts | 4 +- .../remoteAgentHostSessionsProvider.test.ts | 2 + 27 files changed, 699 insertions(+), 26 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 5a9f832f7af..56702dba08c 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -1087,6 +1087,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC case 'root/sessionAdded': case 'root/sessionRemoved': case 'root/sessionSummaryChanged': + case 'root/progress': case 'auth/required': { this._logService.trace(`[RemoteAgentHostProtocol] Notification: ${msg.method}`); // The case narrows `msg.method` to a single literal; the matching params diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 48e60ab8056..b411706d905 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -653,6 +653,13 @@ export interface IAgentCreateSessionConfig { */ readonly turnIdMapping?: ReadonlyMap; }; + /** + * MCP-style opt-in progress token from the client's `createSession`. When + * set, the service reports any long-running session bring-up work — chiefly + * the lazy first-use SDK download — as `progress` notifications carrying + * this token, so the client can correlate them to this call. + */ + readonly progressToken?: string; } /** Options for creating an additional chat within a session. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index ec859badb21..d3decf3e05a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -0259a7e +d35ed4a diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index 5b5271dd892..7746387f6dd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -143,3 +143,82 @@ export interface SessionSummaryChangedParams { */ changes: Partial; } + +// ─── progress ──────────────────────────────────────────────────────────────── + +/** + * Generic progress notification for a long-running operation. + * + * A client opts in to progress for a request by including a `progressToken` in + * that request (today: the `progressToken` field on `createSession`). If the + * server does long-running work to service the request — e.g. lazily + * downloading an agent's native SDK the first time a session of that provider + * is materialized — it emits `progress` notifications carrying the same token. + * + * The notification is operation-agnostic: it says nothing about *what* is + * progressing. The client correlates `progressToken` back to the request it + * originated from (and thus the UI surface awaiting it) and renders its own + * localized indicator. The same channel serves any future long-running + * operation without a new method. + * + * Semantics: + * + * - `progress` is monotonically non-decreasing for a given `progressToken`. + * - `total` is present only when the server knows the magnitude up front + * (e.g. a `Content-Length`); when absent the client SHOULD show an + * indeterminate indicator. + * - The operation is complete when `progress === total`. The server MUST emit a + * final frame satisfying `progress === total`; when the total was never + * known, it sets `total` to the final `progress` on that frame. No further + * frames reference the token afterwards. + * - The server MAY emit no progress at all (e.g. the work was already done); + * the client then never shows an indicator. + * - Like all notifications this is ephemeral and is **not** replayed on + * reconnect. A client that never receives the terminal frame SHOULD expire + * the indicator after an idle timeout. + * + * @category Protocol Notifications + * @method root/progress + * @direction Server → Client + * @messageType Notification + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "root/progress", + * "params": { + * "channel": "ahp-root://", + * "progressToken": "9b2c1f7e-4a0d-4e2b-8b1a-2f7e4a0d4e2b", + * "progress": 18874368, + * "total": 41957498 + * } + * } + * ``` + */ +export interface ProgressParams { + /** Channel URI this notification belongs to (the root channel). */ + channel: URI; + /** + * Echoes the `progressToken` the client supplied on the originating request + * (e.g. the `progressToken` field of `createSession`), correlating this frame + * to that call. Unique across the client's active requests. + */ + progressToken: string; + /** + * Progress so far, in operation-defined units (e.g. bytes received). + * Monotonically non-decreasing for a given `progressToken`. + */ + progress: number; + /** + * Total when known up front (e.g. from a `Content-Length`); omitted ⇒ + * indeterminate. The operation is complete once `progress === total`. + */ + total?: number; + /** + * Optional human-readable progress message. The client owns its own + * (localized) presentation derived from the originating request; generic + * clients that don't track the token MAY display this instead. + */ + message?: string; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index 268856b57cc..41fda59f9e3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -93,6 +93,19 @@ export interface CreateSessionParams extends BaseParams { * `clientId` the creating client supplied in `initialize`. */ activeClient?: SessionActiveClient; + /** + * Opt-in progress token. When set, the client is offering to receive + * `progress` notifications (see `ProgressParams`) for any long-running work + * the server does to bring this session up — most notably the lazy, + * first-use download of the provider's native SDK. The server echoes this + * exact token on every `progress` frame so the client can correlate it to + * this `createSession` call (and the UI awaiting it). + * + * The token MUST be unique across the client's active requests. The server + * MAY ignore it (e.g. when nothing long-running is needed), in which case no + * `progress` notifications are emitted. + */ + progressToken?: string; } // ─── disposeSession ────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index bf010f1d70a..3eb000893c7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -15,7 +15,7 @@ import type { CreateResourceWatchParams, CreateResourceWatchResult } from '../ch import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../channels-changeset/commands.js'; import type { ActionEnvelope } from './actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams } from '../channels-root/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams } from '../channels-root/notifications.js'; import type { AuthRequiredParams } from './notifications.js'; import type { OtlpExportLogsParams, OtlpExportTracesParams, OtlpExportMetricsParams } from '../channels-otlp/notifications.js'; import type { AhpError } from './errors.js'; @@ -166,6 +166,7 @@ export interface ServerNotificationMap { 'root/sessionAdded': { params: SessionAddedParams }; 'root/sessionRemoved': { params: SessionRemovedParams }; 'root/sessionSummaryChanged': { params: SessionSummaryChangedParams }; + 'root/progress': { params: ProgressParams }; 'auth/required': { params: AuthRequiredParams }; 'otlp/exportLogs': { params: OtlpExportLogsParams }; 'otlp/exportTraces': { params: OtlpExportTracesParams }; diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index d7bb394ea74..c177e0ce871 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -182,6 +182,7 @@ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in ProtocolNotificationMe 'root/sessionAdded': '0.1.0', 'root/sessionRemoved': '0.1.0', 'root/sessionSummaryChanged': '0.1.0', + 'root/progress': '0.5.0', 'auth/required': '0.1.0', 'otlp/exportLogs': '0.2.0', 'otlp/exportTraces': '0.2.0', diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index a8b72183236..f6cd6c39983 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -79,6 +79,7 @@ export { type SessionAddedParams, type SessionRemovedParams, type SessionSummaryChangedParams, + type ProgressParams, type AuthRequiredParams, } from './protocol/notifications.js'; @@ -92,6 +93,7 @@ export const NotificationType = { SessionAdded: 'root/sessionAdded', SessionRemoved: 'root/sessionRemoved', SessionSummaryChanged: 'root/sessionSummaryChanged', + Progress: 'root/progress', AuthRequired: 'auth/required', } as const; export type NotificationType = typeof NotificationType[keyof typeof NotificationType]; @@ -131,7 +133,7 @@ import type { RootConfigChangedAction, } from './protocol/actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, AuthRequiredParams } from './protocol/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_ } from './protocol/action-origin.generated.js'; /** @@ -144,6 +146,7 @@ export type ProtocolNotification = | ({ type: 'root/sessionAdded' } & SessionAddedParams) | ({ type: 'root/sessionRemoved' } & SessionRemovedParams) | ({ type: 'root/sessionSummaryChanged' } & SessionSummaryChangedParams) + | ({ type: 'root/progress' } & ProgressParams) | ({ type: 'auth/required' } & AuthRequiredParams); export type RootAction = IRootAction_; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index cc6e9cd1c2b..629a0cf7444 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -7,7 +7,7 @@ import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, type Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { joinPath } from '../../../base/common/resources.js'; import { isWindows } from '../../../base/common/platform.js'; @@ -29,7 +29,7 @@ import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; @@ -132,6 +132,9 @@ async function startAgentHost(): Promise { // Create the real service implementation that lives in this process let agentService: AgentService; let instantiationService: IInstantiationService; + // Hoisted out of the `try` below so the protocol handlers (constructed + // after the block) can forward agent-SDK download progress to clients. + let sdkDownloadProgress: Event | undefined; try { // Build the DI container early so the git service can be created via // `createInstance` (it needs IFileService + INativeEnvironmentService). @@ -162,8 +165,9 @@ async function startAgentHost(): Promise { // Register the agent SDK downloader BEFORE any service that injects it // (ClaudeAgentSdkService and CodexAgent below). The downloader resolves // dev-override env var → on-disk cache → product.agentSdks download. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); diServices.set(ICopilotApiService, copilotApiService); diServices.set(ICopilotBranchNameGenerator, instantiationService.createInstance(CopilotBranchNameGenerator)); @@ -227,6 +231,23 @@ async function startAgentHost(): Promise { logService.error('Failed to create AgentService', err); throw err; } + + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both the local (IPC) and any external (WebSocket) renderer + // receive them via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 6b26b821fbf..252dd06cda9 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -15,6 +15,7 @@ globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta. import * as fs from 'fs'; import * as os from 'os'; +import type { Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { raceTimeout } from '../../../base/common/async.js'; import { joinPath } from '../../../base/common/resources.js'; @@ -41,7 +42,7 @@ import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentService } from './agentService.js'; @@ -249,6 +250,7 @@ async function main(): Promise { diServices.set(IAgentService, agentService); // Register agents + let sdkDownloadProgress: Event | undefined; if (!options.quiet) { // Production agents (require DI) const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); @@ -273,8 +275,9 @@ async function main(): Promise { process.env[AgentHostCodexAgentSdkRootEnvVar] = options.codexSdkRoot; } // Register the agent SDK downloader BEFORE any service that injects it. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); diServices.set(IClaudeProxyService, claudeProxyService); const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); @@ -311,6 +314,22 @@ async function main(): Promise { } } + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both local (IPC) and remote (WebSocket) renderers receive them + // via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + if (options.enableMockAgent) { // Dynamic import to avoid bundling test code in production import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 24305095ab2..0fc1c1fdb92 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -9,7 +9,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type ProgressParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus, SessionSummaryMeta } from '../common/state/sessionState.js'; @@ -1241,4 +1241,21 @@ export class AgentHostStateManager extends Disposable { }); } } + + /** + * Emit a generic progress notification on the root channel, correlated to + * the originating request by {@link ProgressParams.progressToken}. Routed to + * clients through the same {@link onDidEmitNotification} path as session + * notifications, so both the local (IPC proxy) and remote (WebSocket + * {@link ProtocolServerHandler}) renderers receive it without any + * transport-specific special casing. Progress for host-level work (e.g. a + * shared SDK download) rides the root channel rather than a per-session one. + */ + emitProgress(progress: Omit): void { + this._onDidEmitNotification.fire({ + type: 'root/progress', + channel: ROOT_STATE_URI, + ...progress, + }); + } } diff --git a/src/vs/platform/agentHost/node/agentSdkDownloader.ts b/src/vs/platform/agentHost/node/agentSdkDownloader.ts index 32c86eacaa1..55ce50b79f6 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloader.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloader.ts @@ -8,9 +8,12 @@ import * as tar from 'tar'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import * as path from '../../../base/common/path.js'; import { format2 } from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; import { detectLibcSync, type LibcFamily } from '../../../base/node/libc.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { FileOperationError, FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; @@ -47,6 +50,12 @@ import { IRequestContext } from '../../../base/parts/request/common/request.js'; export interface IAgentSdkPackage { /** Key under `product.agentSdks` — e.g. `'claude'`, `'codex'`. */ readonly id: string; + /** + * Brand display name for user-facing progress, e.g. `'Claude'`, `'Codex'`. + * The downloader puts this on {@link IAgentSdkDownloadProgress.displayName} + * so clients can build a localized "Downloading {displayName} agent…" label. + */ + readonly displayName: string; /** Env var that, when set, becomes the SDK root and short-circuits the download. */ readonly devOverrideEnvVar: string; /** @@ -111,9 +120,46 @@ export function resolveSdkTarget( export const IAgentSdkDownloader = createDecorator('agentSdkDownloader'); +/** Lifecycle phase of a single SDK download (downloader-internal). */ +export type AgentSdkDownloadPhase = 'started' | 'progress' | 'completed' | 'failed'; + +/** + * A process-global download-progress sample fired on + * {@link IAgentSdkDownloader.onDidDownloadProgress}. The downloader owns the + * lifecycle: one `started`, throttled `progress` frames, then exactly one + * terminal `completed` / `failed` — all sharing a `downloadId`. Concurrent + * `loadSdkRoot` callers for the same tarball are deduped, so they observe one + * shared download (one `downloadId`). + */ +export interface IAgentSdkDownloadProgress { + /** Stable id for one download; coalesces frames and distinguishes concurrent fetches. */ + readonly downloadId: string; + /** Package id, e.g. `'claude'` / `'codex'`. */ + readonly packageId: string; + /** Brand display name, e.g. `'Claude'`. */ + readonly displayName: string; + /** Lifecycle phase of this frame. */ + readonly phase: AgentSdkDownloadPhase; + /** Bytes written so far. Monotonically non-decreasing within a `downloadId`. */ + readonly receivedBytes: number; + /** Total bytes from `Content-Length`, or `undefined` when unknown (indeterminate). */ + readonly totalBytes: number | undefined; + /** Short, non-localized failure reason; present only when `phase: 'failed'`. */ + readonly error?: string; +} + export interface IAgentSdkDownloader { readonly _serviceBrand: undefined; + /** + * Fires while a tarball is being fetched (cold cache only): one `started`, + * throttled `progress` samples, then one terminal `completed` / `failed`. + * Never fires for dev-override or cache-hit resolutions (no bytes move). + * Process-global so a single subscriber (the protocol server) can forward + * progress to clients regardless of which session triggered the fetch. + */ + readonly onDidDownloadProgress: Event; + /** * Returns the absolute path of the SDK root directory — the directory that * contains the package's `node_modules/` subtree. Callers resolve the @@ -138,6 +184,20 @@ export interface IAgentSdkDownloader { * download. */ isAvailable(pkg: IAgentSdkPackage): boolean; + + /** + * True iff {@link loadSdkRoot} would resolve WITHOUT a network download — + * the dev override is set, or a completed cache for the configured version + * already exists on disk. False when product config is present but the + * cache is cold (a fetch would be required), and false when neither an + * override nor product config is configured. + * + * Performs at most a single sentinel `exists` check and never downloads. + * Eager / background callers (e.g. a provider listing its sessions at + * startup) use this to avoid kicking off a multi-second cold download + * before the user has asked for anything. + */ + isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise; } // #endregion @@ -147,9 +207,31 @@ export interface IAgentSdkDownloader { /** How long a `loadSdkRoot` failure latches before we try again. */ const LOAD_FAILURE_NEGATIVE_CACHE_MS = 30_000; -export class AgentSdkDownloader implements IAgentSdkDownloader { +/** + * Minimum gap between download-progress samples. A 70-95MB tarball over a fast + * link produces thousands of chunks; without throttling we'd flood the progress + * channel. ~250ms keeps the percentage visibly moving without spamming. + */ +const PROGRESS_EMIT_THROTTLE_MS = 250; + +/** + * Parses a `Content-Length` header into a positive integer byte count, or + * `undefined` when the header is absent, an array, or not a clean integer. + */ +function parseContentLength(header: string | string[] | undefined): number | undefined { + if (typeof header !== 'string' || !/^\d+$/.test(header)) { + return undefined; + } + const parsed = parseInt(header, 10); + return parsed > 0 ? parsed : undefined; +} + +export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloader { declare readonly _serviceBrand: undefined; + private readonly _onDidDownloadProgress = this._register(new Emitter()); + readonly onDidDownloadProgress: Event = this._onDidDownloadProgress.event; + /** * In-flight downloads keyed by the destination `cacheDir` (which * already encodes `//`). Concurrent @@ -180,7 +262,9 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { @IRequestService private readonly _requestService: IRequestService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, - ) { } + ) { + super(); + } isAvailable(pkg: IAgentSdkPackage): boolean { if (process.env[pkg.devOverrideEnvVar]) { @@ -189,6 +273,22 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return !!this._productService.agentSdks?.[pkg.id] && resolveSdkTarget(pkg) !== undefined; } + async isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise { + if (process.env[pkg.devOverrideEnvVar]) { + return true; + } + const config = this._productService.agentSdks?.[pkg.id]; + if (!config) { + return false; + } + const sdkTarget = resolveSdkTarget(pkg); + if (!sdkTarget) { + return false; + } + const sentinel = URI.joinPath(URI.file(this._cacheDir(pkg.id, config.version, sdkTarget)), '.complete'); + return this._fileService.exists(sentinel); + } + async loadSdkRoot(pkg: IAgentSdkPackage, token: CancellationToken): Promise { // 1. Dev override. const override = process.env[pkg.devOverrideEnvVar]; @@ -310,9 +410,21 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { await this._delIgnoringMissing(tmpDirUri); await this._fileService.createFolder(tmpDirUri); + // Fire the download lifecycle on the process-global event so a single + // subscriber (the protocol server) can forward it to clients. One + // `started`, throttled `progress` from `_fetch`, then a terminal frame. + const downloadId = generateUuid(); + let lastReceived = 0; + let lastTotal: number | undefined; + this._fireProgress(pkg, downloadId, 'started', 0, undefined); + try { const tarballPath = path.join(tmpDir, 'sdk.tgz'); - await this._fetch(url, tarballPath, token); + await this._fetch(url, tarballPath, token, (receivedBytes, totalBytes) => { + lastReceived = receivedBytes; + lastTotal = totalBytes; + this._fireProgress(pkg, downloadId, 'progress', receivedBytes, totalBytes); + }); await this._extractTarGz(tarballPath, tmpDir); await this._fileService.del(URI.file(tarballPath)); @@ -334,6 +446,7 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { } catch (err) { if (await this._handleRenameLoser(err, sentinel, tmpDirUri)) { this._logService.info(`[AgentSdkDownloader] ${pkg.id}: lost rename race, using existing cache`); + this._fireProgress(pkg, downloadId, 'completed', lastReceived, lastTotal); return cacheDir; } throw err; @@ -341,21 +454,44 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { const elapsed = Math.round((Date.now() - start) / 1000); this._logService.info(`[AgentSdkDownloader] ${pkg.id}: downloaded in ${elapsed}s`); + this._fireProgress(pkg, downloadId, 'completed', lastTotal ?? lastReceived, lastTotal); return cacheDir; } catch (err) { await this._delIgnoringMissing(tmpDirUri); if (token.isCancellationRequested) { + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, 'cancelled'); throw new CancellationError(); } + const message = err instanceof Error ? err.message : String(err); + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, message); throw new Error( `Failed to download ${pkg.id} SDK from ${url} ` + `(cache target: ${cacheDir}). ` + `Set ${pkg.devOverrideEnvVar} to a local SDK root to bypass. ` + - `Cause: ${err instanceof Error ? err.message : String(err)}`, + `Cause: ${message}`, ); } } + private _fireProgress( + pkg: IAgentSdkPackage, + downloadId: string, + phase: AgentSdkDownloadPhase, + receivedBytes: number, + totalBytes: number | undefined, + error?: string, + ): void { + this._onDidDownloadProgress.fire({ + downloadId, + packageId: pkg.id, + displayName: pkg.displayName, + phase, + receivedBytes, + totalBytes, + ...(error !== undefined ? { error } : {}), + }); + } + private async _handleRenameLoser( err: unknown, sentinel: URI, @@ -375,7 +511,12 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return true; } - private async _fetch(url: string, dest: string, token: CancellationToken): Promise { + private async _fetch( + url: string, + dest: string, + token: CancellationToken, + onBytes?: (receivedBytes: number, totalBytes: number | undefined) => void, + ): Promise { // Delegate to IRequestService (corporate proxy, strictSSL, kerberos, // retries, redirect follow). `fs.createWriteStream` (not // `IFileService.writeFile`) so that cancelling a multi-MB download @@ -401,9 +542,31 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { throw new Error(`HTTP ${statusCode} fetching ${url}`); } + // The CDN sends `Content-Length` for these static tarballs, which lets + // us report determinate percentage progress. A missing/garbled header + // degrades gracefully to an indeterminate (byte-count only) report. + const totalBytes = parseContentLength(context.res.headers['content-length']); + await new Promise((resolve, reject) => { const out = fs.createWriteStream(dest); let settled = false; + // Throttle progress so a fast link doesn't fire thousands of + // samples. The first chunk always passes (lastEmit starts at 0) + // and 'end' forces a final sample, so consumers see a start and a + // 100% finish regardless of chunk timing. + let receivedBytes = 0; + let lastEmitTime = 0; + const emitBytes = (force: boolean) => { + if (!onBytes) { + return; + } + const now = Date.now(); + if (!force && now - lastEmitTime < PROGRESS_EMIT_THROTTLE_MS) { + return; + } + lastEmitTime = now; + onBytes(receivedBytes, totalBytes); + }; const settleResolve = () => { if (settled) { return; } settled = true; @@ -428,11 +591,16 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { // resume on 'drain'. out.on('drain', () => context.stream.resume()); context.stream.on('data', chunk => { + receivedBytes += chunk.byteLength; + emitBytes(false); if (!out.write(chunk.buffer)) { context.stream.pause(); } }); - context.stream.on('end', () => out.end()); + context.stream.on('end', () => { + emitBytes(true); + out.end(); + }); context.stream.on('error', settleReject); }); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 7cfca6949d2..dc9355148a9 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -116,6 +116,17 @@ export class AgentService extends Disposable implements IAgentService { private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ private readonly _sessionToProvider = new Map(); + /** + * Pending download-progress tokens, keyed by provider id then session URI + * (toString). A session is registered here when its `createSession` carries + * a {@link IAgentCreateSessionConfig.progressToken} and removed once it + * materializes (the SDK is now resolved) or is disposed. The SDK download is + * host-level and shared across every session of a provider, and its progress + * events are keyed by package id (which equals the provider id) — so on a + * download frame we fan it out as one `progress` notification per waiting + * session's token. See {@link emitDownloadProgress}. + */ + private readonly _downloadProgressTokens = new Map>(); /** Subscriptions to provider progress events; cleared when providers change. */ private readonly _providerSubscriptions = this._register(new DisposableStore()); private readonly _authService: AgentHostAuthenticationService; @@ -599,6 +610,17 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); this._sessionToProvider.set(session.toString(), provider.id); + // Register the client's opt-in progress token so a cold SDK download + // triggered at materialization (first message) reports back to the + // `createSession` call that requested it. Cleared on materialize/dispose. + if (config?.progressToken) { + let bySession = this._downloadProgressTokens.get(provider.id); + if (!bySession) { + bySession = new Map(); + this._downloadProgressTokens.set(provider.id, bySession); + } + bySession.set(session.toString(), config.progressToken); + } this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); // Resolve config and seed the initial customization set in parallel so @@ -814,6 +836,9 @@ export class AgentService extends Disposable implements IAgentService { */ private _onDidMaterializeSession(e: IAgentMaterializeSessionEvent): void { const sessionKey = e.session.toString(); + // The session is now materialized — its SDK is resolved (any cold + // download already finished), so no further progress is expected for it. + this._clearDownloadProgressToken(sessionKey); const state = this._stateManager.getSessionState(sessionKey); if (!state) { this._logService.warn(`[AgentService] onDidMaterializeSession for unknown session: ${sessionKey}`); @@ -848,6 +873,49 @@ export class AgentService extends Disposable implements IAgentService { this._changesetCoordinator.onSessionMaterialized(sessionKey); } + /** Remove a session's pending download-progress token, if any. */ + private _clearDownloadProgressToken(sessionKey: string): void { + for (const [provider, bySession] of this._downloadProgressTokens) { + if (bySession.delete(sessionKey) && bySession.size === 0) { + this._downloadProgressTokens.delete(provider); + } + } + } + + /** + * Fan a host-level SDK download-progress sample out to the clients waiting + * on it. The downloader fires process-global frames keyed by package id + * (which equals the provider id); we emit one generic `progress` + * notification per session of that provider that supplied a + * {@link IAgentCreateSessionConfig.progressToken} on `createSession`, so the + * client can correlate it back to that call. A terminal frame reports + * `total === progress` (using `receivedBytes` when the size was never known) + * so clients dismiss the indicator, and clears the provider's tokens. + * + * `displayName` is the provider's brand noun (e.g. `Claude`). It is woven + * into the notification's localized, human-readable `message` (e.g. + * "Downloading Claude agent…") so a generic client can render the indicator + * verbatim without knowing the resource is an agent SDK. + */ + emitDownloadProgress(packageId: string, displayName: string, receivedBytes: number, totalBytes: number | undefined, terminal: boolean): void { + const bySession = this._downloadProgressTokens.get(packageId); + if (!bySession || bySession.size === 0) { + return; + } + // On a terminal frame force `progress === total` so clients treat the + // operation as complete (covers both the determinate case and the + // indeterminate one where `totalBytes` was never known, plus failures — + // the real error surfaces via the session-failure path). + const total = terminal ? receivedBytes : totalBytes; + const message = localize('agentHost.download.agentSdkTitle', "Downloading {0} agent…", displayName); + for (const progressToken of new Set(bySession.values())) { + this._stateManager.emitProgress({ progressToken, progress: receivedBytes, total, message }); + } + if (terminal) { + this._downloadProgressTokens.delete(packageId); + } + } + /** * Fire-and-forget probe that resolves the session's git state for its * working directory (if any) and merges it into `state._meta.git` via @@ -966,6 +1034,7 @@ export class AgentService extends Disposable implements IAgentService { if (provider) { await provider.disposeSession(session); this._sessionToProvider.delete(session.toString()); + this._clearDownloadProgressToken(session.toString()); } this._changesetCoordinator.onSessionDisposed(session.toString()); this._sideEffects.cancelSessionTitleGeneration(session.toString()); @@ -2211,6 +2280,7 @@ export class AgentService extends Disposable implements IAgentService { } await Promise.all(promises); this._sessionToProvider.clear(); + this._downloadProgressTokens.clear(); } // ---- helpers ------------------------------------------------------------ diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 2efcd916ee7..cbbdb69889c 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -900,6 +900,15 @@ export class ClaudeAgent extends Disposable implements IAgent { // sibling Copilot provider gets nuked too. Catch and log instead. let sdkEntries: readonly SDKSessionInfo[]; try { + // Don't trigger a cold SDK download just to populate the session + // list at startup. When the SDK isn't local yet, surface an empty + // list; the download fires (with host-level progress) once the user + // starts a session, and the next `listSessions` — driven by the + // renderer's post-turn refresh — returns the full list. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring session list until a session triggers the download'); + return []; + } sdkEntries = await this._sdkService.listSessions(); } catch (err) { this._logService.warn('[Claude] SDK listSessions failed; surfacing empty list', err); diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts index 5846daa8265..0955bf4e60e 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts @@ -22,6 +22,7 @@ import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; */ export const ClaudeSdkPackage: IAgentSdkPackage = { id: 'claude', + displayName: 'Claude', devOverrideEnvVar: AgentHostClaudeSdkRootEnvVar, hasSeparateMuslLinuxPackage: true, }; @@ -54,6 +55,16 @@ export interface IClaudeAgentSdkService { getSessionMessages(sessionId: string, options?: GetSessionMessagesOptions): Promise; listSubagents(sessionId: string, options?: ListSubagentsOptions): Promise; getSubagentMessages(sessionId: string, agentId: string, options?: GetSubagentMessagesOptions): Promise; + + /** + * True iff the SDK can be loaded WITHOUT a network download — a dev + * override or dev bare-import is available, or a previously-downloaded SDK + * is cached on disk. Eager / background callers (e.g. `listSessions` at + * startup) gate on this so listing sessions never kicks off a multi-second + * cold download before the user has started a session. + */ + canLoadWithoutDownload(): Promise; + forkSession(sessionId: string, options?: ForkSessionOptions): Promise; createSdkMcpServer(options: { name: string; @@ -132,6 +143,17 @@ export class ClaudeAgentSdkService implements IClaudeAgentSdkService { return sdk.listSessions(undefined); } + async canLoadWithoutDownload(): Promise { + // A dev override (explicit SDK root) is always local. So is the dev + // bare-import path, which is taken when there is no product config — + // `isAvailable` is false exactly in that case. Otherwise the SDK comes + // from the downloader, which is only local once it has been cached. + if (process.env[AgentHostClaudeSdkRootEnvVar] || !this._downloader.isAvailable(ClaudeSdkPackage)) { + return true; + } + return this._downloader.isSdkResolvableWithoutDownload(ClaudeSdkPackage); + } + async getSessionInfo(sessionId: string): Promise { const sdk = await this._getSdk(); return sdk.getSessionInfo(sessionId); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 6b62c179afb..9833f8489b8 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -449,6 +449,7 @@ interface IConnectionReady { */ export const CodexSdkPackage: IAgentSdkPackage = { id: 'codex', + displayName: 'Codex', devOverrideEnvVar: AgentHostCodexAgentSdkRootEnvVar, hasSeparateMuslLinuxPackage: false, }; @@ -1774,7 +1775,16 @@ export class CodexAgent extends Disposable implements IAgent { if (!session.workingDirectory) { return; } - void this._materializeIfNeeded(session, false).then(() => { + void (async () => { + // Prewarm is a background latency optimization, not a user action, + // so it must NOT trigger a cold SDK download. When the SDK isn't + // local yet, skip prewarm; the first `sendMessage` materializes the + // thread and fires the (host-level progress-reported) download then. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info(`[Codex] SDK not downloaded yet; skipping prewarm for session=${session.sessionUri.toString()} until a message triggers the download`); + return; + } + await this._materializeIfNeeded(session, false); if (session.prewarmClaimed || session.threadId === undefined) { return; } @@ -1783,7 +1793,7 @@ export class CodexAgent extends Disposable implements IAgent { void this._expirePrewarm(session); }, CodexPrewarmTtlMs); session.prewarmTimer = prewarmTimer; - }).catch(err => { + })().catch(err => { this._logService.warn(`[Codex] prewarm failed session=${session.sessionUri.toString()}: ${err instanceof Error ? err.message : String(err)}`); }); } @@ -2251,6 +2261,15 @@ export class CodexAgent extends Disposable implements IAgent { if (!this._githubToken) { return []; } + // Don't connect (and trigger a cold SDK download) just to list threads + // at startup. When the SDK isn't local yet, surface an empty list; the + // download fires (with host-level progress) once the user starts a + // session, and the next `listSessions` — driven by the renderer's + // post-turn refresh — returns the full list. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info('[Codex] SDK not downloaded yet; deferring thread/list until a session triggers the download'); + return []; + } try { const conn = await this._ensureConnection(); const response = await conn.client.request<'thread/list', ThreadListResponse>('thread/list', { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 318a27b5a8a..ab418cb98dd 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1140,6 +1140,7 @@ export class ProtocolServerHandler extends Disposable { fork, config: params.config, activeClient: params.activeClient, + progressToken: params.progressToken, }); } catch (err) { if (err instanceof ProtocolError) { diff --git a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts index e41f93800ce..6daa264734d 100644 --- a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts @@ -19,7 +19,7 @@ import type { IFileService } from '../../../files/common/files.js'; import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { RequestService } from '../../../request/node/requestService.js'; -import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage } from '../../node/agentSdkDownloader.js'; +import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage, type IAgentSdkDownloadProgress } from '../../node/agentSdkDownloader.js'; import { ClaudeSdkPackage } from '../../node/claude/claudeAgentSdkService.js'; import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; import type { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -128,7 +128,7 @@ suite('resolveSdkTarget', () => { ensureNoDisposablesAreLeakedInTestSuite(); function fakePkg(hasSeparateMuslLinuxPackage: boolean): IAgentSdkPackage { - return { id: 'test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; + return { id: 'test', displayName: 'Test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; } test('returns - for supported (platform, arch)', () => { @@ -239,13 +239,13 @@ suite('AgentSdkDownloader', () => { version: productConfig?.version ?? '1.0.0', urlTemplate: productConfig?.urlTemplate ?? `http://127.0.0.1:${server.port}/sdk-{sdkTarget}.tgz`, }; - return new AgentSdkDownloader( + return disposables.add(new AgentSdkDownloader( makeEnvService(userDataPath), makeProductService(config), makeRequestService(disposables), makeFileService(disposables), new NullLogService(), - ); + )); } test('isAvailable: false when no env override and no product config', () => { @@ -280,6 +280,32 @@ suite('AgentSdkDownloader', () => { assert.ok(fs.existsSync(path.join(root, '.complete'))); }); + test('loadSdkRoot: reports monotonic download progress ending at totalBytes', async () => { + const downloader = makeDownloader(); + const samples: IAgentSdkDownloadProgress[] = []; + disposables.add(downloader.onDidDownloadProgress(p => samples.push(p))); + + await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); + + const tarballSize = (await fsp.stat(fixture.tarballPath)).size; + // One `started`, ≥1 `progress`, one terminal `completed`, all sharing a + // single downloadId and carrying the brand display name. + assert.ok(samples.length >= 2, 'expected at least a started and a completed frame'); + assert.strictEqual(samples[0].phase, 'started'); + const completed = samples[samples.length - 1]; + assert.strictEqual(completed.phase, 'completed'); + assert.ok(samples.every(s => s.downloadId === samples[0].downloadId), 'all frames share one downloadId'); + assert.ok(samples.every(s => s.displayName === 'Claude'), 'all frames carry the brand display name'); + + // receivedBytes is monotonically non-decreasing and reaches the total + // reported via Content-Length. + for (let i = 1; i < samples.length; i++) { + assert.ok(samples[i].receivedBytes >= samples[i - 1].receivedBytes, 'receivedBytes must be monotonic'); + } + assert.strictEqual(completed.totalBytes, tarballSize); + assert.strictEqual(completed.receivedBytes, tarballSize); + }); + test('loadSdkRoot: cache hit returns immediately without re-downloading', async () => { const downloader = makeDownloader(); await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 46d9aec9fc1..9cd445526eb 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -356,6 +356,10 @@ class ProxyRoundTripSdkService implements IClaudeAgentSdkService { return []; } + async canLoadWithoutDownload(): Promise { + return true; + } + async getSessionInfo(_sessionId: string): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 8c685bdec85..e70dc30cfb3 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -228,6 +228,10 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { return this.sessionList; } + async canLoadWithoutDownload(): Promise { + return true; + } + /** * Fake for {@link IClaudeAgentSdkService.getSessionInfo}. Tests stage * `sessionList` and the fake searches it by id; setting @@ -763,7 +767,9 @@ function forkSourceMessages(sourceId: string): SessionMessage[] { function stubAgentSdkDownloader(): IAgentSdkDownloader { return { _serviceBrand: undefined, + onDidDownloadProgress: Event.None, isAvailable: () => false, + isSdkResolvableWithoutDownload: async () => false, loadSdkRoot: () => { throw new Error('test stub: downloader.loadSdkRoot should not be called'); }, }; } diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts index ee3986de42c..ea9d3aa751d 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts @@ -42,6 +42,7 @@ class FakeSdkService implements IClaudeAgentSdkService { getSubagentMessagesCalls: { sessionId: string; agentId: string }[] = []; async listSessions(): Promise { return []; } + async canLoadWithoutDownload(): Promise { return true; } async getSessionInfo(_id: string): Promise { return undefined; } async startup(_p: { options: Options; initializeTimeoutMs?: number }): Promise { throw new Error('not used'); } async query(_params: { prompt: string | AsyncIterable; options?: Options }): Promise { throw new Error('not used'); } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index f0d2f17a0eb..571d3cea8d6 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -13,7 +13,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../../common/state/sessionActions.js'; +import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ProgressParams } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, type SessionSummary } from '../../common/state/sessionState.js'; @@ -1988,6 +1988,70 @@ suite('ProtocolServerHandler', () => { }); }); + suite('download progress channel', () => { + // Progress is emitted on the state manager (so it reaches both local + // IPC and remote WebSocket renderers through the same path as session + // notifications). This suite verifies the handler forwards each frame to + // connected clients as a `progress` notification on the root channel. + // Spun up per-test with a private state manager so the outer suite is + // unaffected. + let dlStateManager: AgentHostStateManager; + let dlServer: MockProtocolServer; + let dlAgentService: MockAgentService; + let localDisposables: DisposableStore; + + setup(() => { + localDisposables = new DisposableStore(); + dlStateManager = localDisposables.add(new AgentHostStateManager(new NullLogService())); + dlServer = localDisposables.add(new MockProtocolServer()); + dlAgentService = new MockAgentService(); + dlAgentService.setStateManager(dlStateManager); + localDisposables.add(dlAgentService); + localDisposables.add(new ProtocolServerHandler( + dlAgentService, + dlStateManager, + dlServer, + { defaultDirectory: URI.file('/home/testuser').toString() }, + localDisposables.add(new AgentHostFileSystemProvider()), + new NullLogService(), + )); + }); + + teardown(() => { + localDisposables.dispose(); + }); + + function connectDownloadClient(clientId: string): MockProtocolTransport { + const transport = new MockProtocolTransport(); + dlServer.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId, + })); + return transport; + } + + function findProgress(sent: ProtocolMessage[]): ProgressParams[] { + return sent + .filter(isJsonRpcNotification) + .filter((m): m is AhpNotification & { method: 'root/progress'; params: ProgressParams } => m.method === 'root/progress') + .map(m => m.params); + } + + test('forwards each progress frame to connected clients on the root channel', () => { + const transport = connectDownloadClient('client-dl-1'); + + dlStateManager.emitProgress({ progressToken: 't1', progress: 0, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 500, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 1000, total: 1000, message: 'Claude' }); + + const frames = findProgress(transport.sent); + assert.deepStrictEqual(frames.map(f => f.progress), [0, 500, 1000]); + assert.ok(frames.every(f => f.progressToken === 't1' && f.message === 'Claude' && f.total === 1000)); + assert.ok(frames.every(f => (f as ProgressParams & { channel: string }).channel === 'ahp-root://'), 'frames are broadcast on the root channel'); + }); + }); + suite('resource watches', () => { test('subscribe to a resource-watch channel returns the descriptor + bumps refcount; envelopes are routed', async () => { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 2511e859011..d4bb83800d2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { disposableTimeout, raceTimeout } from '../../../../../base/common/async.js'; +import { disposableTimeout, DeferredPromise, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { arrayEquals, structuralEquals } from '../../../../../base/common/equals.js'; @@ -27,13 +27,14 @@ import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/ import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, AgentSelection, ChangesSummary, type ChangesetFile, type ClientPluginCustomization, Customization, CustomizationType, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isChatAction, isSessionAction, NotificationType, type ProgressParams } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, ROOT_STATE_URI, SessionMeta, SessionSummaryMeta, StateComponents, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IProgressService, IProgressStep, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; @@ -1264,6 +1265,12 @@ class NewSession extends Disposable { session: backendUri, workingDirectory: this.workspaceUri, config: this._config?.values, + // MCP-style opt-in: offer to receive `progress` for any + // long-running bring-up (chiefly the lazy first-use SDK + // download, which fires later at first-message + // materialization). The host echoes this token on each + // `progress` frame so `_handleProgress` can correlate it. + progressToken: generateUuid(), ...(this._selectedAgent ? { agent: { uri: this._selectedAgent.uri } } : {}), ...(this._initialActiveClient ? { activeClient: this._initialActiveClient } : {}), }); @@ -1381,6 +1388,19 @@ class NewSession extends Disposable { * URI-scheme mapping for session metadata, the agent-provider lookup, and * the browse UI. */ +/** + * One in-flight download, tracked by + * {@link BaseAgentHostSessionsProvider._activeDownloads}. Owns the lifecycle + * of a single notification progress: `report` pushes a step, `complete` + * resolves the backing deferred so the notification is dismissed. + */ +interface IActiveDownload { + /** Last reported determinate percentage, used to compute progress increments. */ + lastPercent: number; + report(step: IProgressStep): void; + complete(): void; +} + export abstract class BaseAgentHostSessionsProvider extends Disposable implements IAgentHostSessionsProvider { abstract readonly id: string; @@ -1432,6 +1452,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map(); + /** + * Active progress indicators keyed by `progressToken`. Each entry owns one + * long-running notification progress (opened on the first frame) that is + * driven via {@link IActiveDownload.report} and dismissed via + * {@link IActiveDownload.complete} once `progress >= total`. See + * {@link _handleProgress}. + */ + private readonly _activeDownloads = new Map(); + /** * Temporary session that has been sent (first turn dispatched) but not yet * committed by the backend session list. Shown in the session list until the @@ -1535,6 +1564,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement @IStorageService protected readonly _storageService: IStorageService, @IDialogService protected readonly _dialogService: IDialogService, @IWorkspaceTrustManagementService protected readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IProgressService protected readonly _progressService: IProgressService, ) { super(); this._register(toDisposable(() => { @@ -1543,6 +1573,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } this._sessionCache.clear(); })); + this._register(toDisposable(() => { + for (const download of this._activeDownloads.values()) { + download.complete(); + } + this._activeDownloads.clear(); + })); } // -- Subclass hooks ------------------------------------------------------- @@ -3213,6 +3249,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._handleSessionRemoved(n.session); } else if (n.type === NotificationType.SessionSummaryChanged) { this._handleSessionSummaryChanged(n.session, n.changes); + } else if (n.type === NotificationType.Progress) { + this._handleProgress(n); } })); @@ -3381,6 +3419,81 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement }); } + /** + * Render a generic `progress` notification as a notification progress bar. + * Progress is correlated to its originating `createSession` call by + * {@link ProgressParams.progressToken}; today the only producer is the + * agent host's lazy, first-use SDK download (shared across a provider's + * sessions). We surface one notification per `progressToken`: determinate + * when the host knows the `total` (`Content-Length`), or a byte-count spinner + * otherwise. The operation is complete — and the notification dismissed — + * once `progress >= total`. The human-readable brand noun rides on + * {@link ProgressParams.message}. + */ + private _handleProgress(progress: ProgressParams): void { + // New AI UI must stay hidden when the user has turned AI features off. + if (this._baseConfigurationService.getValue(ChatConfiguration.AIDisabled)) { + return; + } + + // Complete when we reach the (possibly server-synthesized) total. The + // host emits a terminal frame with `progress === total` for success, + // indeterminate completion, and failure alike; real errors surface via + // the session-failure path, not here. + const isComplete = progress.total !== undefined && progress.progress >= progress.total; + if (isComplete) { + this._activeDownloads.get(progress.progressToken)?.complete(); + this._activeDownloads.delete(progress.progressToken); + return; + } + + let entry = this._activeDownloads.get(progress.progressToken); + if (!entry) { + // First frame for this token (or one we joined late): open one + // long-running notification progress and drive it via `report` + // until a terminal frame resolves `deferred`. + const deferred = new DeferredPromise(); + let report: ((step: IProgressStep) => void) | undefined; + // `message` is the host-supplied, already-localized human-readable + // title (e.g. "Downloading Claude agent…"). Render it verbatim so + // this stays a generic progress indicator that makes no assumption + // about what is being downloaded; fall back only if a producer omits it. + const title = progress.message ?? localize('agentHost.download.titleFallback', "Downloading…"); + this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title, + }, + p => { + report = step => p.report(step); + return deferred.p; + }, + ); + entry = { + lastPercent: 0, + report: step => report?.(step), + complete: () => deferred.complete(), + }; + this._activeDownloads.set(progress.progressToken, entry); + } + + if (progress.total && progress.total > 0) { + const percent = Math.max(0, Math.min(100, Math.round((progress.progress / progress.total) * 100))); + const increment = percent - entry.lastPercent; + entry.lastPercent = percent; + entry.report({ + message: localize('agentHost.download.percent', "{0}%", percent), + increment: increment > 0 ? increment : 0, + total: 100, + }); + } else { + // No total: indeterminate. Show megabytes received so the user + // still sees the download making progress. + const megabytes = (progress.progress / (1024 * 1024)).toFixed(1); + entry.report({ message: localize('agentHost.download.megabytes', "{0} MB", megabytes) }); + } + } + private _handleConfigChanged(session: string, config: Record, replace: boolean): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 8e4ecd200a8..273fff0f1d1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -18,6 +18,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../platform/progress/common/progress.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; @@ -87,8 +88,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IDialogService dialogService: IDialogService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IProgressService progressService: IProgressService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService, progressService); this._isSessionsWindow = environmentService.isSessionsWindow; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 327a1193904..10c24840015 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -24,6 +24,7 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { IDialogService, IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -338,6 +339,7 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen }); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IStorageService, options?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IProgressService, {}); instantiationService.stub(IGitHubService, options?.gitHubService ?? new class extends mock() { override findPullRequestNumberByHeadBranch = async () => undefined; }()); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index bbade91785a..ede47257d83 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -27,6 +27,7 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../platform/progress/common/progress.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -219,8 +220,9 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @IAgentHostActiveClientService activeClientService: IAgentHostActiveClientService, @IDialogService dialogService: IDialogService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IProgressService progressService: IProgressService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService, progressService); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index aac28637641..ed527769082 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -23,6 +23,7 @@ import { IDialogService, IFileDialogService } from '../../../../../../platform/d import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -217,6 +218,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne lookupLanguageModel: () => undefined, }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IProgressService, {}); instantiationService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path, }); From 3cce7ab3dc2fa694bfd03971bfac3ed76abfee2d Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Fri, 26 Jun 2026 19:16:22 +0200 Subject: [PATCH 02/59] signing commit From c87216ff49fd3a6a24485279c9637f57e8a9edde Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 29 Jun 2026 11:38:54 +0200 Subject: [PATCH 03/59] signing commit From 5fa5adcec924c086fb7d00f32790e04a91a99340 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 29 Jun 2026 12:00:59 +0200 Subject: [PATCH 04/59] agentHost: surface SDK download as one progress stream per provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK download is provider-global — one physical fetch shared by every session of a provider — but each new-session composer minted its own `progressToken` and the host fanned one frame out per token. So clicking New Session repeatedly stacked duplicate download toasts, and switching provider left the old toast hung (its provisional session was discarded, so no terminal frame ever arrived for that token). Fix the modelling at the source: the host now emits a SINGLE `progress` stream per download, keyed by the download's own stable identity (the package id), rather than fanning per session token. The per-`createSession` `progressToken` is kept purely as an opt-in signal — the host tracks which sessions are interested and emits while at least one is, but the token it echoes is the download's, shared across the provider. The client keys indicators by `progressToken` (now stable per download) and dismisses on the host's terminal frame, so there is exactly one indicator with a deterministic lifecycle — no message-keying or idle-timeout heuristics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentService.ts | 82 ++++++++++--------- .../browser/baseAgentHostSessionsProvider.ts | 41 +++++----- 2 files changed, 65 insertions(+), 58 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6ef83fb9364..d1b15d03f5e 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -120,16 +120,17 @@ export class AgentService extends Disposable implements IAgentService { /** Maps each active session URI (toString) to its owning provider. */ private readonly _sessionToProvider = new Map(); /** - * Pending download-progress tokens, keyed by provider id then session URI - * (toString). A session is registered here when its `createSession` carries - * a {@link IAgentCreateSessionConfig.progressToken} and removed once it + * Sessions that have opted in to bring-up progress, keyed by provider id. + * A session is added here when its `createSession` carries a + * {@link IAgentCreateSessionConfig.progressToken} and removed once it * materializes (the SDK is now resolved) or is disposed. The SDK download is - * host-level and shared across every session of a provider, and its progress - * events are keyed by package id (which equals the provider id) — so on a - * download frame we fan it out as one `progress` notification per waiting - * session's token. See {@link emitDownloadProgress}. + * host-level and shared across every session of a provider, so this only + * records *interest*: as long as one or more sessions of a provider is + * registered, {@link emitDownloadProgress} surfaces that provider's download as a single + * progress stream keyed by the download's own identity (the package id), + * rather than one stream per session. */ - private readonly _downloadProgressTokens = new Map>(); + private readonly _downloadProgressInterest = new Map>(); /** Subscriptions to provider progress events; cleared when providers change. */ private readonly _providerSubscriptions = this._register(new DisposableStore()); private readonly _authService: AgentHostAuthenticationService; @@ -617,16 +618,18 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); this._sessionToProvider.set(session.toString(), provider.id); - // Register the client's opt-in progress token so a cold SDK download - // triggered at materialization (first message) reports back to the - // `createSession` call that requested it. Cleared on materialize/dispose. + // Record this session's opt-in so a cold SDK download triggered at + // materialization (first message) is surfaced as progress. The download + // is provider-global, so we only track interest here; emission is keyed + // by the download's own identity, not this token. Cleared on + // materialize/dispose. if (config?.progressToken) { - let bySession = this._downloadProgressTokens.get(provider.id); - if (!bySession) { - bySession = new Map(); - this._downloadProgressTokens.set(provider.id, bySession); + let sessions = this._downloadProgressInterest.get(provider.id); + if (!sessions) { + sessions = new Set(); + this._downloadProgressInterest.set(provider.id, sessions); } - bySession.set(session.toString(), config.progressToken); + sessions.add(session.toString()); } this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); @@ -843,7 +846,7 @@ export class AgentService extends Disposable implements IAgentService { const sessionKey = e.session.toString(); // The session is now materialized — its SDK is resolved (any cold // download already finished), so no further progress is expected for it. - this._clearDownloadProgressToken(sessionKey); + this._clearDownloadProgressInterest(sessionKey); const state = this._stateManager.getSessionState(sessionKey); if (!state) { this._logService.warn(`[AgentService] onDidMaterializeSession for unknown session: ${sessionKey}`); @@ -883,24 +886,26 @@ export class AgentService extends Disposable implements IAgentService { this._changesetCoordinator.onSessionMaterialized(sessionKey); } - /** Remove a session's pending download-progress token, if any. */ - private _clearDownloadProgressToken(sessionKey: string): void { - for (const [provider, bySession] of this._downloadProgressTokens) { - if (bySession.delete(sessionKey) && bySession.size === 0) { - this._downloadProgressTokens.delete(provider); + /** Drop a session's download-progress opt-in, if any. */ + private _clearDownloadProgressInterest(sessionKey: string): void { + for (const [provider, sessions] of this._downloadProgressInterest) { + if (sessions.delete(sessionKey) && sessions.size === 0) { + this._downloadProgressInterest.delete(provider); } } } /** - * Fan a host-level SDK download-progress sample out to the clients waiting - * on it. The downloader fires process-global frames keyed by package id - * (which equals the provider id); we emit one generic `progress` - * notification per session of that provider that supplied a - * {@link IAgentCreateSessionConfig.progressToken} on `createSession`, so the - * client can correlate it back to that call. A terminal frame reports - * `total === progress` (using `receivedBytes` when the size was never known) - * so clients dismiss the indicator, and clears the provider's tokens. + * Surface a host-level SDK download as client progress. The downloader fires + * process-global frames keyed by package id (which equals the provider id); + * because the download is shared across every session of that provider, we + * emit a SINGLE `progress` stream keyed by that package id — not one per + * session — so the client shows exactly one indicator no matter how many + * sessions of the provider are awaiting it. Frames are only emitted while at + * least one session has opted in (supplied a + * {@link IAgentCreateSessionConfig.progressToken} on `createSession`). A + * terminal frame reports `total === progress` (using `receivedBytes` when the + * size was never known) so the client dismisses the indicator deterministically. * * `displayName` is the provider's brand noun (e.g. `Claude`). It is woven * into the notification's localized, human-readable `message` (e.g. @@ -908,8 +913,8 @@ export class AgentService extends Disposable implements IAgentService { * verbatim without knowing the resource is an agent SDK. */ emitDownloadProgress(packageId: string, displayName: string, receivedBytes: number, totalBytes: number | undefined, terminal: boolean): void { - const bySession = this._downloadProgressTokens.get(packageId); - if (!bySession || bySession.size === 0) { + const sessions = this._downloadProgressInterest.get(packageId); + if (!sessions || sessions.size === 0) { return; } // On a terminal frame force `progress === total` so clients treat the @@ -918,11 +923,12 @@ export class AgentService extends Disposable implements IAgentService { // the real error surfaces via the session-failure path). const total = terminal ? receivedBytes : totalBytes; const message = localize('agentHost.download.agentSdkTitle', "Downloading {0} agent…", displayName); - for (const progressToken of new Set(bySession.values())) { - this._stateManager.emitProgress({ progressToken, progress: receivedBytes, total, message }); - } + // `progressToken` is the download's own stable identity (the package id), + // shared by every session of the provider, so the client coalesces all + // frames into one indicator and dismisses it on the terminal frame. + this._stateManager.emitProgress({ progressToken: packageId, progress: receivedBytes, total, message }); if (terminal) { - this._downloadProgressTokens.delete(packageId); + this._downloadProgressInterest.delete(packageId); } } @@ -990,7 +996,7 @@ export class AgentService extends Disposable implements IAgentService { if (provider) { await provider.disposeSession(session); this._sessionToProvider.delete(session.toString()); - this._clearDownloadProgressToken(session.toString()); + this._clearDownloadProgressInterest(session.toString()); } this._changesetCoordinator.onSessionDisposed(session.toString()); this._sideEffects.cancelSessionTitleGeneration(session.toString()); @@ -2260,7 +2266,7 @@ export class AgentService extends Disposable implements IAgentService { } await Promise.all(promises); this._sessionToProvider.clear(); - this._downloadProgressTokens.clear(); + this._downloadProgressInterest.clear(); } // ---- helpers ------------------------------------------------------------ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index f56de968d27..2730ffd387a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1479,11 +1479,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement protected readonly _sessionCache = new Map(); /** - * Active progress indicators keyed by `progressToken`. Each entry owns one - * long-running notification progress (opened on the first frame) that is - * driven via {@link IActiveDownload.report} and dismissed via - * {@link IActiveDownload.complete} once `progress >= total`. See - * {@link _handleProgress}. + * Active progress indicators keyed by `progressToken`. Today's only + * producer is the agent host's lazy, first-use SDK download, which is + * provider-global: the host emits a single stream per download keyed by the + * download's own stable identity (so distinct sessions of a provider share + * one indicator). Each entry owns one long-running notification progress + * (opened on the first frame), driven via {@link IActiveDownload.report} and + * dismissed via {@link IActiveDownload.complete} once `progress >= total`. + * See {@link _handleProgress}. */ private readonly _activeDownloads = new Map(); @@ -3451,14 +3454,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** * Render a generic `progress` notification as a notification progress bar. - * Progress is correlated to its originating `createSession` call by - * {@link ProgressParams.progressToken}; today the only producer is the - * agent host's lazy, first-use SDK download (shared across a provider's - * sessions). We surface one notification per `progressToken`: determinate - * when the host knows the `total` (`Content-Length`), or a byte-count spinner - * otherwise. The operation is complete — and the notification dismissed — - * once `progress >= total`. The human-readable brand noun rides on - * {@link ProgressParams.message}. + * Progress is correlated by {@link ProgressParams.progressToken}; today's + * only producer is the agent host's lazy, first-use SDK download, which the + * host surfaces as a single stream per provider keyed by the download's own + * stable identity — so one indicator per download regardless of how many + * sessions await it. Determinate when the host knows the `total` + * (`Content-Length`), or a byte-count spinner otherwise. The operation is + * complete — and the notification dismissed — once `progress >= total`. The + * human-readable brand noun rides on {@link ProgressParams.message}. */ private _handleProgress(progress: ProgressParams): void { // New AI UI must stay hidden when the user has turned AI features off. @@ -3479,15 +3482,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement let entry = this._activeDownloads.get(progress.progressToken); if (!entry) { - // First frame for this token (or one we joined late): open one - // long-running notification progress and drive it via `report` - // until a terminal frame resolves `deferred`. + // First frame for this download: open one long-running notification + // progress and drive it via `report` until a terminal frame resolves + // `deferred`. `message` is the host-supplied, already-localized title + // (e.g. "Downloading Claude agent…"); render it verbatim so this stays + // a generic indicator that makes no assumption about what's downloading. const deferred = new DeferredPromise(); let report: ((step: IProgressStep) => void) | undefined; - // `message` is the host-supplied, already-localized human-readable - // title (e.g. "Downloading Claude agent…"). Render it verbatim so - // this stays a generic progress indicator that makes no assumption - // about what is being downloaded; fall back only if a producer omits it. const title = progress.message ?? localize('agentHost.download.titleFallback', "Downloading…"); this._progressService.withProgress( { From 051422f15fd26646c3c47cd1654debc413f5d660 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Mon, 29 Jun 2026 16:50:50 +0200 Subject: [PATCH 05/59] signing commit From 164290d7325de66b701d6b4af3336f1cc0e11ace Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:25:31 +0000 Subject: [PATCH 06/59] AgentHost - move creating the changesets to the coordinator (#323631) --- .../node/agentHostChangesetCoordinator.ts | 11 +++++++++- .../platform/agentHost/node/agentService.ts | 22 +------------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 108529b7382..a2031d06c37 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { IAgentSessionMetadata } from '../common/agentService.js'; -import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, formatSessionChangesetDescription as formatBranchChangesChangesetDescription, parseChangesetUri } from '../common/changesetUri.js'; +import { buildBranchChangesetUri, buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, formatSessionChangesetDescription as formatBranchChangesChangesetDescription, parseChangesetUri } from '../common/changesetUri.js'; import { ChangesetFileMonitorCoordinator } from './agentHostChangesetFileMonitorCoordinator.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetService, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS } from '../common/agentHostChangesetService.js'; @@ -71,6 +71,9 @@ export class AgentHostChangesetCoordinator extends Disposable { * `SessionReady` is dispatched. */ onSessionCreated(sessionStr: string): void { + const changesets = buildDefaultChangesetCatalogue(sessionStr); + this._stateManager.setSessionChangesets(sessionStr, changesets); + this._changesets.registerStaticChangesets(sessionStr); } @@ -82,6 +85,9 @@ export class AgentHostChangesetCoordinator extends Disposable { * keys. */ onSessionRestored(sessionStr: string, metadata: IChangesetSessionMetadata): void { + const changesets = buildDefaultChangesetCatalogue(sessionStr); + this._stateManager.setSessionChangesets(sessionStr, changesets); + this._changesets.registerStaticChangesets(sessionStr); this._changesets.restorePersistedStaticChangesets(sessionStr, { branchRaw: metadata[META_CHANGESET_BRANCH], @@ -101,6 +107,9 @@ export class AgentHostChangesetCoordinator extends Disposable { * because the working directory was not yet known. */ onSessionMaterialized(sessionStr: string): void { + const changesets = buildDefaultChangesetCatalogue(sessionStr); + this._stateManager.setSessionChangesets(sessionStr, changesets); + this._changesets.onWorkingDirectoryAvailable(sessionStr); this._changesetFileMonitor.onSessionMaterialized(sessionStr); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index ebd1f29200c..19ad3beba92 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -22,7 +22,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection. import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; -import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; import { ActionType, ActionEnvelope, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -688,19 +688,6 @@ export class AgentService extends Disposable implements IAgentService { this._persistConfigValues(session, sessionConfig.values); } - // Initial changeset state is established as part of session creation, - // never deferred to materialization. Two halves: (1) the catalogue - // is seeded on `state.changesets` via `setSessionChangesets` right - // after `createSession`; (2) the backing per-changeset states are - // registered by `_changesetCoordinator.onSessionCreated` here. Both - // run before `SessionReady` is dispatched. Any future change must - // keep both halves at create time so client subscriptions resolve - // `_attachGitState` strips them once the git probe confirms the - // resolved working directory is not a git repo. Pinned by item-2 - // regression tests in `agentService.test.ts`. - const changesets = buildDefaultChangesetCatalogue(session.toString()); - this._stateManager.setSessionChangesets(session.toString(), changesets); - this._changesetCoordinator.onSessionCreated(session.toString()); if (!created.provisional) { @@ -848,10 +835,6 @@ export class AgentService extends Disposable implements IAgentService { // Attach git state for the working directory (if present) void this._gitStateService.refreshSessionGitState(e.session.toString(), e.workingDirectory); - // Initialize the session's changesets from the catalogue - const changesets = buildDefaultChangesetCatalogue(sessionKey); - this._stateManager.setSessionChangesets(sessionKey, changesets); - // If a client subscribed to this session's uncommitted changeset // before the working directory was known, the coordinator drains // the deferred refresh now that the working directory is set. @@ -1643,9 +1626,6 @@ export class AgentService extends Disposable implements IAgentService { // persisted title so they reappear after a process restart. promises.push(this._restorePeerChats(agent, session)); - const changesets = buildDefaultChangesetCatalogue(sessionStr); - this._stateManager.setSessionChangesets(sessionStr, changesets); - // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue // itself is seeded on `state.changesets` synchronously by the From 17b7f793231efb0a3b7e85c46c1ec0219ebd563c Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Mon, 29 Jun 2026 15:27:50 -0700 Subject: [PATCH 07/59] Fix some potential memory leaks in the terminal suggest addon (#323627) --- .../terminalContrib/suggest/browser/terminalSuggestAddon.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 6943deb2217..5fa0fd05f02 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -782,7 +782,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._logService.trace('SuggestAddon#_showCompletions setCompletionModel'); suggestWidget.setCompletionModel(model); - this._register(suggestWidget.onDidFocus(() => this._terminal?.focus())); if (!this._promptInputModel || !explicitlyInvoked && model.items.length === 0) { return; } @@ -825,6 +824,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._register(this._suggestWidget.onDidSelect(async e => this.acceptSelectedSuggestion(e))); this._register(this._suggestWidget.onDidHide(() => this._terminalSuggestWidgetVisibleContextKey.reset())); this._register(this._suggestWidget.onDidShow(() => this._terminalSuggestWidgetVisibleContextKey.set(true))); + this._register(this._suggestWidget.onDidFocus(() => this._terminal?.focus())); this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TerminalSettingId.FontFamily) || e.affectsConfiguration(TerminalSettingId.FontSize) || e.affectsConfiguration(TerminalSettingId.LineHeight) || e.affectsConfiguration(TerminalSettingId.FontFamily) || e.affectsConfiguration('editor.fontSize') || e.affectsConfiguration('editor.fontFamily')) { this._onDidFontConfigurationChange.fire(); @@ -1045,7 +1045,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest hideSuggestWidget(cancelAnyRequest: boolean): void { this._discoverability?.resetTimer(); if (cancelAnyRequest) { - this._cancellationTokenSource?.cancel(); + this._cancellationTokenSource?.dispose(true); this._cancellationTokenSource = undefined; // Also cancel any pending resolution requests this._currentSuggestionDetails?.cancel(); From 79eae77b5bcd1b144208eb2abe439a273ac07d6d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 30 Jun 2026 00:54:57 +0200 Subject: [PATCH 08/59] sessions: keyboard shortcuts for chat actions within a session (#323630) * sessions: keyboard shortcuts for chat actions within a session Add keyboard navigation and quick pickers for the chats inside an Agents window session: - Ctrl/Cmd+Shift+] / [ to go to the next/previous chat tab (positional) - Ctrl/Cmd+W to close the active chat tab (deletes a draft, hides a committed chat) instead of the session, when the active chat is closeable - Ctrl+Tab / Ctrl+Shift+Tab as an editor-switcher (MRU) chat switcher over open chats (no input, hold-cycle-release), gated above the session-history secondary on that chord and falling back to session navigation otherwise - Ctrl/Cmd+Shift+O (and the 'Go to Chat in Session' palette command) opens a searchable picker listing Open and Closed chats; selecting a closed chat reopens it Each chat row shows a chat icon, untitled drafts are skipped, and the tab order is exposed as a reactive IActiveSession.visibleChatTabs observable (implemented in VisibleSession) instead of a standalone helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: contribute chat tab close button via a menu Introduce Menus.SessionChatTab and contribute the close-chat command (sessions.chatCompositeBar.closeChat) to it, so the chat tab strip renders each non-main tab's close button from the menu (forwarding the tab's chat as the action argument) instead of a hand-rolled action bar. The same command keeps the Ctrl/Cmd+W keybinding for the active chat. The active-chat precondition is moved to the keybinding's when-clause so the per-tab menu button is not disabled based on which chat is active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address PR review feedback on chat navigation - Compare chat/main-chat URIs via IUriIdentityService.extUri.isEqual (and isEqual in sessionContextKeys) instead of URI.toString(), matching the rest of the sessions navigation stack (openChat) and avoiding path-casing/encoding pitfalls. - Include in-composer drafts in the Ctrl+Tab MRU switcher so its switchable set matches its SessionHasMultipleOpenChatsContext gate: a session with one committed open chat plus a draft now offers a useful two-entry switch instead of winning the chord with a single item. The searchable palette flow still skips drafts and lists closed chats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: fix chatCompositeBar fixture missing visibleChatTabs The component fixture's mock IActiveSession did not implement the new visibleChatTabs observable, so ChatCompositeBar.setSession crashed while reading it. Provide visibleChatTabs in the mock so the fixtures render again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 2 +- src/vs/sessions/browser/menus.ts | 1 + .../browser/parts/chatCompositeBar.ts | 49 +-- src/vs/sessions/common/contextkeys.ts | 2 + .../test/browser/layoutControllerTestUtils.ts | 1 + .../browser/agentHostSkillButtons.test.ts | 1 + .../sessions/browser/sessionsActions.ts | 341 +++++++++++++++++- .../sessionsTerminalContribution.test.ts | 1 + .../sessions/browser/visibleSessions.ts | 8 +- .../sessions/common/sessionContextKeys.ts | 14 + .../sessions/common/sessionsManagement.ts | 6 + .../test/browser/sessionNavigation.test.ts | 1 + .../browser/sessionsListModelService.test.ts | 2 +- .../test/browser/visibleSessions.test.ts | 34 +- .../sessions/chatCompositeBar.fixture.ts | 1 + 15 files changed, 424 insertions(+), 40 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index f0c7bf5783b..bdb7beba86d 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -125,7 +125,7 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: - A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind — plus the workspace label, and a hover showing the working-directory path and git branch, registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. -- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **open chats**: it is shown as soon as the session has more than one open chat (**including in-composer draft chats**) and hidden again when chats are removed back down to just the main chat. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single open chat); once the strip is shown (more than one open chat, including drafts) the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionHasMultipleOpenChatsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. +- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **open chats**: it is shown as soon as the session has more than one open chat (**including in-composer draft chats**) and hidden again when chats are removed back down to just the main chat. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single open chat); once the strip is shown (more than one open chat, including drafts) the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionHasMultipleOpenChatsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. While the tab strip is shown the chat tabs are keyboard-navigable from the active session: `Ctrl/Cmd+Shift+]` / `Ctrl/Cmd+Shift+[` go to the next / previous chat (wrapping), `Ctrl/Cmd+W` closes the active chat tab (deleting an in-composer draft, hiding a committed chat) instead of the session — the same command (`sessions.chatCompositeBar.closeChat`) is contributed to the per-tab `Menus.SessionChatTab`, which the chat tab strip renders as each non-main tab's close button (forwarding the tab's chat as the action argument), and `Ctrl+Tab` / `Ctrl+Shift+Tab` open a **chat switcher** — a no-input, editor-switcher (MRU) quick pick over the session's **open** chats (skipping in-composer drafts), each shown with a chat icon (hold the modifier, press `Tab` to cycle, release to select), winning over the session-history secondary on that chord while the session has multiple open chats and falling back to session navigation otherwise (and to the editor's own `Ctrl+Tab` switcher while a quick pick is already open, since the open chords are gated on `inQuickOpen` negated); the **Go to Chat in Session** palette command (`sessions.showChatsPicker`, `Ctrl/Cmd+Shift+O`, gated on more than one committed chat) opens a **searchable** variant that additionally lists **Closed** chats in a separate group (selecting one reopens it) — these commands (`sessions.chatCompositeBar.navigateNextChat` / `navigatePreviousChat` / `closeChat` and `sessions.showChatsPicker` in `contrib/sessions/browser/sessionsActions.ts`) outrank the session-level navigation/close chords via a higher keybinding weight and are gated on `SessionHasMultipleOpenChatsContext` / `SessionActiveChatIsClosableContext`. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index cdf13f32672..1606b7e7fed 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -37,6 +37,7 @@ export const Menus = { SessionWorkspaceManage: new MenuId('Sessions.SessionWorkspaceManage'), SessionBarToolbar: new MenuId('SessionsSessionBarToolbar'), SessionConversations: new MenuId('SessionsSessionConversations'), + SessionChatTab: new MenuId('SessionsSessionChatTab'), SessionChatTabBar: new MenuId('SessionsSessionChatTabBar'), SessionHeaderMeta: new MenuId('SessionsSessionHeaderMeta'), SessionHeaderContext: MenuId.SessionHeaderContext, diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 662ad3c1b12..cb68ad6c07d 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -26,7 +26,7 @@ import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { localize } from '../../../nls.js'; -import { ChatOriginKind, IChat, SessionStatus } from '../../services/sessions/common/session.js'; +import { IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; @@ -203,18 +203,17 @@ export class ChatCompositeBar extends Disposable { // chat. The strip's own trailing "New Chat" action follows this visibility. this._setVisible(false); store.add(autorun(reader => { - const openChats = session.openChats.read(reader); const mainChat = session.mainChat.read(reader); const activeChatUri = session.activeChat.read(reader)?.resource.toString() ?? ''; const mainChatUri = mainChat.resource.toString(); - const visibleOpenChats = openChats.filter(chat => chat.origin?.kind !== ChatOriginKind.Tool); - this._rebuildTabs(visibleOpenChats, activeChatUri, mainChatUri); + const tabs = session.visibleChatTabs.read(reader); + this._rebuildTabs(tabs, activeChatUri, mainChatUri); // Archived sessions are read-only, so disable the trailing New Chat // action (mirrors the header action's SessionIsArchivedContext gating). this._newChatAction.enabled = !session.isArchived.read(reader); - this._setVisible(session.isCreated.read(reader) && visibleOpenChats.length > 1); + this._setVisible(session.isCreated.read(reader) && tabs.length > 1); })); } @@ -304,33 +303,19 @@ export class ChatCompositeBar extends Disposable { tab.appendChild(indicator); - // Close action — only for non-main chats, always visible. For a committed - // chat, closing hides it from the tab strip (reopenable from the chats - // dropdown in the session header); use Delete to remove it permanently. For - // an untitled (in-composer) draft chat there is nothing to reopen, so the - // action deletes the draft outright (no confirmation) and is labelled - // accordingly so keyboard/screen-reader users know it is destructive. - if (!isMainChat) { - const isDraft = chat.status.get() === SessionStatus.Untitled; - const closeAction = this._tabDisposables.add(new Action( - 'chatCompositeBar.closeChat', - isDraft ? localize('deleteDraftChat', "Delete Chat") : localize('closeChat', "Close"), - ThemeIcon.asClassName(Codicon.close), - true, - async () => { - if (!this._session) { - return; - } - if (chat.status.get() === SessionStatus.Untitled) { - await this._sessionsManagementService.deleteChat(this._session, chat.resource, { skipConfirmation: true }); - } else { - await this._sessionsService.closeChat(this._session, chat); - } - }, - )); - const actionBar = this._tabDisposables.add(new ActionBar(tab, { actionViewItemProvider: undefined })); - actionBar.push(closeAction, { icon: true, label: false }); - actionBar.getContainer().classList.add('chat-composite-bar-tab-actions'); + // Close button — contributed via Menus.SessionChatTab (the chat tab menu). + // Only non-main chats can be closed; the main chat lives and dies with its + // session, so its tab renders no actions toolbar. The tab's chat (and its + // session) is forwarded as the action argument. + if (!isMainChat && session) { + const actionsContainer = $('.chat-composite-bar-tab-actions'); + tab.appendChild(actionsContainer); + const tabToolbar = this._tabDisposables.add(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, Menus.SessionChatTab, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + menuOptions: { shouldForwardArgs: true }, + toolbarOptions: { primaryGroup: () => true }, + })); + tabToolbar.context = { session, chat }; } this._tabsContainer.appendChild(tab); diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 9a03c5bab9f..de3ffadf39f 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -29,6 +29,7 @@ export const SessionIsMaximizedContext = new RawContextKey('sessionIsMa export const SessionSupportsMultipleChatsContext = new RawContextKey('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Conversations menu visibility")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat, i.e. the chat tab strip is shown. Used to hide the header New Chat button, which the tab strip then offers instead")); +export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed/deleted from the tab strip (i.e. it is not the main chat). Used to scope the close-chat keybinding")); export const SessionIsReadContext = new RawContextKey('sessionIsRead', true, localize('sessionIsRead', "Whether the session has been marked as read")); export const SessionIsArchivedContext = new RawContextKey('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); export const SessionHasChangesContext = new RawContextKey('sessionHasChanges', false, localize('sessionHasChanges', "Whether the session view's session has pending changes (insertions or deletions)")); @@ -75,6 +76,7 @@ export const SessionIsolationPickerVisibleContext = new RawContextKey(' //#region < --- Sessions Picker --- > export const SessionsPickerVisibleContext = new RawContextKey('sessionsPickerVisible', false, localize('sessionsPickerVisible', "Whether the sessions picker is visible")); +export const SessionChatsPickerVisibleContext = new RawContextKey('sessionChatsPickerVisible', false, localize('sessionChatsPickerVisible', "Whether the chats picker (chats within the active session) is visible")); //#endregion diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 9093a9966e8..b779e6681fd 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -100,6 +100,7 @@ export function makeSession(resource: URI, opts?: { sticky: observableValue('sticky', false), openChats: observableValue('openChats', [chat]), closedChats: constObservable([]), + visibleChatTabs: constObservable([chat]), }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts index ce455187c6e..4305a0566a5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts @@ -69,6 +69,7 @@ function makeActiveSession(providerId: string): IActiveSession { sticky: observableValue('sticky', false), openChats: observableValue('openChats', [chat]), closedChats: constObservable([]), + visibleChatTabs: constObservable([chat]), } satisfies IActiveSession; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index ad50d0da58c..8ca69c6faaf 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -16,20 +16,21 @@ import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from ' import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { getQuickNavigateHandler } from '../../../../workbench/browser/quickaccess.js'; +import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionChatsPickerVisibleContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; -import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ChatOriginKind, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; @@ -425,6 +426,338 @@ registerAction2(class AddChatToSessionAction extends Action2 { } }); +// -- Chat tab navigation & close (within the active session's tab strip) -- + +// These chords sit just above the session-level navigation/close commands so +// they win while a multi-chat session is focused, falling back to the +// session-level commands when the tab strip is not shown. +const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; + +function navigateChatTab(accessor: ServicesAccessor, direction: 'next' | 'previous'): void { + const sessionsService = accessor.get(ISessionsService); + const sessionsPartService = accessor.get(ISessionsPartService); + const extUri = accessor.get(IUriIdentityService).extUri; + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const tabs = session.visibleChatTabs.get(); + if (tabs.length < 2) { + return; + } + const activeChat = session.activeChat.get(); + const currentIndex = activeChat ? tabs.findIndex(chat => extUri.isEqual(chat.resource, activeChat.resource)) : -1; + const from = currentIndex === -1 ? 0 : currentIndex; + const delta = direction === 'next' ? 1 : -1; + const target = tabs[(from + delta + tabs.length) % tabs.length]; + sessionsService.openChat(session, target.resource); + sessionsPartService.focusSession(session); +} + +registerAction2(class NavigateNextChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.navigateNextChat', + title: localize2('navigateNextChat', "Go to Next Chat"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketRight, + }, + }); + } + override run(accessor: ServicesAccessor): void { + navigateChatTab(accessor, 'next'); + } +}); + +registerAction2(class NavigatePreviousChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.navigatePreviousChat', + title: localize2('navigatePreviousChat', "Go to Previous Chat"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketLeft, + }, + }); + } + override run(accessor: ServicesAccessor): void { + navigateChatTab(accessor, 'previous'); + } +}); + +// The close-chat action is both a keybinding (Ctrl/Cmd+W closes the active chat) +// and a per-tab toolbar action contributed to {@link Menus.SessionChatTab}: the +// chat tab strip renders this menu and forwards the tab's {@link IChatTabContext} +// as the action argument so the button closes that specific tab. +export interface IChatTabContext { + readonly session: IActiveSession; + readonly chat: IChat; +} + +registerAction2(class CloseChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.closeChat', + title: localize2('closeActiveChat', "Close Chat"), + icon: Codicon.close, + // Hidden from the palette: closing a specific chat is contextual (the + // keybinding targets the active chat; the menu targets a tab). + f1: false, + category: SessionsCategories.Sessions, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + // Intercept Ctrl/Cmd+W (which otherwise closes the session) only + // while the active chat is a closeable non-main chat, so it closes + // the chat tab instead — like closing a tab vs the window. + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionActiveChatIsClosableContext), + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, + }, + // Rendered as the tab's close button by the chat tab strip; the main + // chat's tab does not render this menu, so no per-tab gating is needed. + menu: { + id: Menus.SessionChatTab, + group: 'navigation', + order: 10, + }, + }); + } + override async run(accessor: ServicesAccessor, context?: IChatTabContext): Promise { + const sessionsService = accessor.get(ISessionsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const extUri = accessor.get(IUriIdentityService).extUri; + // From the tab menu: act on the forwarded tab's chat. From the keybinding: + // act on the active chat of the active session. + const session = context?.session ?? sessionsService.activeSession.get(); + if (!session) { + return; + } + const chat = context?.chat ?? session.activeChat.get(); + if (!chat || extUri.isEqual(chat.resource, session.mainChat.get().resource)) { + return; + } + // An untitled (in-composer) draft has nothing to reopen, so delete it + // outright; a committed chat is hidden (reopenable). + if (chat.status.get() === SessionStatus.Untitled) { + await sessionsManagementService.deleteChat(session, chat.resource, { skipConfirmation: true }); + } else { + await sessionsService.closeChat(session, chat); + } + } +}); + +// -- Show Chats Picker (chats within the active session) -- + +// A no-input quick pick (pure switcher) over the active session's open chats, +// each shown with a chat icon. Driven by Ctrl+Tab / Ctrl+Shift+Tab in +// editor-switcher (MRU) style: opens with quick navigate active, so holding the +// modifier and pressing Tab cycles and releasing accepts the focused chat. These +// are gated to sessions with more than one open chat at a higher weight than the +// session-history secondary on the same chord, so they fall back to session +// navigation otherwise. The same picker is also reachable from the palette ("Go +// to Chat in Session"), which additionally lists closed chats and skips drafts. + +export const SHOW_CHATS_PICKER_COMMAND_ID = 'sessions.showChatsPicker'; +const QUICK_SWITCH_NEXT_CHAT_ID = 'sessions.quickSwitchNextChat'; +const QUICK_SWITCH_PREVIOUS_CHAT_ID = 'sessions.quickSwitchPreviousChat'; +const CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID = 'sessions.chatsPicker.quickNavigateNext'; +const CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID = 'sessions.chatsPicker.quickNavigatePrevious'; + +// The open chords are gated to not fire while another quick pick is already +// showing (inQuickPickContext negated), so e.g. the editor's own Ctrl+Tab picker +// keeps the chord for its own navigation instead of this opening on top of it. +// The Ctrl+Tab MRU switcher cycles open chats only, so it is gated on more than +// one open tab. (The palette command, which also lists closed chats, is gated on +// more than one committed chat instead.) +const ChatsPickerScopeContext = ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext, inQuickPickContext.negate()); + +function openChatsPicker(accessor: ServicesAccessor, mru?: { readonly backward: boolean }): void { + const sessionsService = accessor.get(ISessionsService); + const quickInputService = accessor.get(IQuickInputService); + const sessionsPartService = accessor.get(ISessionsPartService); + const contextKeyService = accessor.get(IContextKeyService); + const keybindingService = accessor.get(IKeybindingService); + + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const extUri = accessor.get(IUriIdentityService).extUri; + + interface IChatPickItem extends IQuickPickItem { + readonly chat: IChat; + } + + const toItem = (chat: IChat): IChatPickItem => ({ + label: chat.title.get()?.trim() || localize('untitledChat', "Untitled Chat"), + description: fromNow(chat.updatedAt.get(), true, true), + iconClass: ThemeIcon.asClassName(Codicon.commentDiscussion), + chat, + }); + + // MRU mode cycles every open tab (including in-composer drafts) so the set of + // switchable chats matches the SessionHasMultipleOpenChatsContext gate. The + // searchable palette flow instead skips untitled drafts (no meaningful title, + // mirroring the Conversations submenu) and adds the closed chats below. + const openItems = (mru + ? session.visibleChatTabs.get() + : session.visibleChatTabs.get().filter(chat => chat.status.get() !== SessionStatus.Untitled) + ).map(toItem); + // Closed chats are hidden from the tab strip but still reopenable. They are + // only offered in the searchable palette flow — not the Ctrl+Tab MRU switcher, + // which mirrors the editor switcher and cycles open items only. + const closedItems = mru ? [] : session.closedChats.get() + .filter(chat => chat.status.get() !== SessionStatus.Untitled && chat.origin?.kind !== ChatOriginKind.Tool) + .map(toItem); + + // Navigation order: open chats first, then closed chats. + const pickItems = [...openItems, ...closedItems]; + if (pickItems.length === 0) { + return; + } + + const displayItems: (IChatPickItem | IQuickPickSeparator)[] = closedItems.length === 0 + ? openItems + : [ + { type: 'separator', label: localize('openChatsGroup', "Open") }, + ...openItems, + { type: 'separator', label: localize('closedChatsGroup', "Closed") }, + ...closedItems, + ]; + + const activeChat = session.activeChat.get(); + const activeIndex = Math.max(0, activeChat ? pickItems.findIndex(item => extUri.isEqual(item.chat.resource, activeChat.resource)) : -1); + // MRU style starts on the adjacent chat so a single tap+release switches to + // it; palette invocation (non-MRU) focuses the active chat. + const startIndex = mru ? (activeIndex + (mru.backward ? -1 : 1) + pickItems.length) % pickItems.length : activeIndex; + + const disposables = new DisposableStore(); + const picker = disposables.add(quickInputService.createQuickPick({ useSeparators: true })); + picker.items = displayItems; + picker.activeItems = [pickItems[startIndex]]; + if (mru) { + // Editor-switcher style: no filter input, and quick navigate stays active so + // releasing the modifier accepts the focused chat. The modifier is taken + // from the quick-navigate keybinding's chord. + picker.hideInput = true; + picker.quickNavigate = { keybindings: keybindingService.lookupKeybindings(CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID) }; + } else { + // Palette flow: a searchable list across the Open and Closed groups. + picker.placeholder = localize('searchChats', "Search chats by name"); + picker.matchOnDescription = true; + } + + // Expose a context key while the picker is open so the navigate keybindings + // (bound to the same chords) advance the selection instead of re-opening. + const pickerVisibleContext = SessionChatsPickerVisibleContext.bindTo(contextKeyService); + pickerVisibleContext.set(true); + disposables.add(toDisposable(() => pickerVisibleContext.reset())); + + disposables.add(picker.onDidAccept(() => { + const [selected] = picker.selectedItems; + if (selected) { + sessionsService.openChat(session, selected.chat.resource); + sessionsPartService.focusSession(session); + } + picker.hide(); + })); + disposables.add(picker.onDidHide(() => disposables.dispose())); + + picker.show(); +} + +registerAction2(class ShowChatsPickerAction extends Action2 { + constructor() { + super({ + id: SHOW_CHATS_PICKER_COMMAND_ID, + title: localize2('showChatsPicker', "Go to Chat in Session"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleCommittedChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), inQuickPickContext.negate()), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyO, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor); + } +}); + +// Ctrl+Tab / Ctrl+Shift+Tab open the picker in editor-switcher (MRU) mode. Hidden +// from the palette (f1: false) since they only make sense held; the chord wins +// over the session-history secondary via the higher weight while multi-chat. +registerAction2(class QuickSwitchNextChatAction extends Action2 { + constructor() { + super({ + id: QUICK_SWITCH_NEXT_CHAT_ID, + title: localize2('quickSwitchNextChat', "Quick Switch to Next Chat"), + f1: false, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib + 1, + when: ChatsPickerScopeContext, + primary: KeyMod.CtrlCmd | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyCode.Tab }, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor, { backward: false }); + } +}); + +registerAction2(class QuickSwitchPreviousChatAction extends Action2 { + constructor() { + super({ + id: QUICK_SWITCH_PREVIOUS_CHAT_ID, + title: localize2('quickSwitchPreviousChat', "Quick Switch to Previous Chat"), + f1: false, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib + 1, + when: ChatsPickerScopeContext, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.Tab }, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor, { backward: true }); + } +}); + +// While the picker is open, Ctrl+Tab / Ctrl+Shift+Tab cycle forward / backward. +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID, + weight: KeybindingWeight.SessionsContrib + 50, + handler: getQuickNavigateHandler(CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID, true), + when: SessionChatsPickerVisibleContext, + primary: KeyMod.CtrlCmd | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyCode.Tab }, +}); +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID, + weight: KeybindingWeight.SessionsContrib + 50, + handler: getQuickNavigateHandler(CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID, false), + when: SessionChatsPickerVisibleContext, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.Tab }, +}); + export class SessionNewChatActionViewItemContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessions.newChatActionViewItem'; diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 5c70b36d8f6..58b39e39897 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -122,6 +122,7 @@ function makeAgentSession(opts: { sticky: observableValue('test.sticky', false), openChats: observableValue('test.openChats', [chat]), closedChats: constObservable([]), + visibleChatTabs: constObservable([chat]), } satisfies TestActiveSession; return session; } diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 9999cdc721e..02ea0bbf657 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -8,7 +8,7 @@ import { IObservable, ISettableObservable, ITransaction, autorun, derived, obser import { URI } from '../../../../base/common/uri.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IActiveSession } from '../common/sessionsManagement.js'; -import { IChat, ISession, SessionStatus } from '../common/session.js'; +import { IChat, ISession, ChatOriginKind, SessionStatus } from '../common/session.js'; /** * Wraps an {@link ISession} with an active chat observable to form an @@ -36,6 +36,7 @@ export class VisibleSession extends Disposable implements IActiveSession { private readonly _closedChatUris: ISettableObservable>; readonly openChats: IObservable; readonly closedChats: IObservable; + readonly visibleChatTabs: IObservable; constructor( private readonly _session: ISession, @@ -72,6 +73,11 @@ export class VisibleSession extends Disposable implements IActiveSession { } return this._session.chats.read(reader).filter(c => closed.has(c.resource.toString())); }); + // Tab strip contents: the open chats with tool-origin chats (subagents) + // hidden, in the provider's order. + this.visibleChatTabs = derived(this, reader => + this.openChats.read(reader).filter(c => c.origin?.kind !== ChatOriginKind.Tool) + ); } setActiveChat(chat: IChat): void { diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 23736a0a95e..0bd5be0e32e 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { SessionHasChangesContext, @@ -22,6 +23,7 @@ import { SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, + SessionActiveChatIsClosableContext, } from '../../../common/contextkeys.js'; import { ChatOriginKind, ISession, SessionStatus } from './session.js'; import { IActiveSession } from './sessionsManagement.js'; @@ -46,6 +48,7 @@ interface ISessionContextKeys { readonly sticky: IContextKey; readonly hasMultipleCommittedChats: IContextKey; readonly hasMultipleOpenChats: IContextKey; + readonly activeChatIsClosable: IContextKey; } /** @@ -78,6 +81,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey sticky: SessionIsStickyContext.bindTo(contextKeyService), hasMultipleCommittedChats: SessionHasMultipleCommittedChatsContext.bindTo(contextKeyService), hasMultipleOpenChats: SessionHasMultipleOpenChatsContext.bindTo(contextKeyService), + activeChatIsClosable: SessionActiveChatIsClosableContext.bindTo(contextKeyService), }; boundKeysByService.set(contextKeyService, keys); } @@ -151,4 +155,14 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, // More than one open chat (incl. drafts) means the tab strip is shown; the // header then hides its own New Chat button. keys.hasMultipleOpenChats.set((session?.openChats.read(reader).filter(chat => chat.origin?.kind !== ChatOriginKind.Tool).length ?? 0) > 1); + + // The active chat can be closed/deleted from the tab strip only when it is a + // real, non-main chat (the main chat lives and dies with its session). + const activeChat = session?.activeChat.read(reader); + const mainResource = session?.mainChat.read(reader).resource; + keys.activeChatIsClosable.set( + !!activeChat && !!mainResource + && !isEqual(activeChat.resource, mainResource) + && activeChat.origin?.kind !== ChatOriginKind.Tool + ); } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index f501bc4a64d..81c506e73f8 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -118,6 +118,12 @@ export interface IActiveSession extends ISession { /** The closed (hidden from the tab strip) but still reopenable chats. Deleted chats drop out. */ readonly closedChats: IObservable; + + /** + * The chats shown as tabs in the tab strip: {@link openChats} with tool-origin + * chats (subagents) hidden, in the provider's order. + */ + readonly visibleChatTabs: IObservable; } /** diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 36994f65956..f1af8336a2f 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -123,6 +123,7 @@ class MockSessionStore implements ISessionsManagementService { activeChat: observableValue(`test.activeChat-${session.sessionId}`, activeChat), openChats: session.chats, closedChats: constObservable([]), + visibleChatTabs: session.chats, }; this.activeSession.set(active, undefined); } else { diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts index b2af6a14839..c5a229e85cd 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts @@ -362,7 +362,7 @@ suite('SessionsListModelService', () => { service.markRead(session); // Make session the active one - activeSession.set({ ...session, activeChat: constObservable(session.mainChat.get()), isCreated: constObservable(true), sticky: constObservable(false), openChats: session.chats, closedChats: constObservable([]) }, undefined); + activeSession.set({ ...session, activeChat: constObservable(session.mainChat.get()), isCreated: constObservable(true), sticky: constObservable(false), openChats: session.chats, closedChats: constObservable([]), visibleChatTabs: session.chats }, undefined); // Seed the last-known status as InProgress sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index cb67a82dd90..4b3e74b9173 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -12,7 +12,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { VisibleSession, VisibleSessions } from '../../browser/visibleSessions.js'; -import { IChat, ISession } from '../../common/session.js'; +import { ChatOriginKind, IChat, ISession, SessionStatus } from '../../common/session.js'; const stubChat: IChat = { resource: URI.parse('test:///chat'), @@ -1023,3 +1023,35 @@ suite('VisibleSession - open/close chats', () => { }); }); }); + +suite('VisibleSession - visibleChatTabs', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChat(id: string, status = SessionStatus.Completed, origin?: ChatOriginKind): IChat { + return { + ...stubChat, + resource: URI.parse(`test:///chat/${id}`), + title: constObservable(id), + status: constObservable(status), + origin: origin ? { kind: origin } : undefined, + }; + } + + function createSession(chats: IChat[]) { + const base = stubSession('S'); + const session: ISession = { ...base, chats: constObservable(chats), mainChat: constObservable(chats[0]) }; + return disposables.add(new VisibleSession(session, chats[0])); + } + + test('keeps provider order and hides tool-origin chats', () => { + const visible = createSession([ + makeChat('main'), + makeChat('draft', SessionStatus.Untitled), + makeChat('tool', SessionStatus.Completed, ChatOriginKind.Tool), + makeChat('second'), + ]); + + assert.deepStrictEqual(visible.visibleChatTabs.get().map(c => c.title.get()), ['main', 'draft', 'second']); + }); +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts index c3839e2deb9..5b8a0b7dadc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts @@ -47,6 +47,7 @@ function createMockSession(chats: readonly IChat[], activeChat: IChat, sessionTi override readonly chats: IObservable = observableValue('chats', chats); override readonly openChats: IObservable = observableValue('openChats', chats); override readonly closedChats: IObservable = observableValue('closedChats', []); + override readonly visibleChatTabs: IObservable = observableValue('visibleChatTabs', chats); override readonly mainChat: IObservable = observableValue('mainChat', chats[0]); override readonly activeChat: IObservable = observableValue('activeChat', activeChat); override readonly isCreated: IObservable = observableValue('isCreated', true); From 6d52b5c5da4e1a184305dd1175e013588a055f56 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:14:01 -0700 Subject: [PATCH 09/59] policy: let native MDM win over server for managed settings (#323615) * policy: let native MDM win over server for managed settings Flip the managed-settings precedence so native MDM takes priority over the GitHub server endpoint. selectManagedSettings now resolves native MDM first, then server, then the on-disk file, and the Policy Diagnostics report lists the channels in that same precedence order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: update accountPolicyService tests, comment, and docs for MDM precedence Flip the AccountPolicyService precedence unit tests so native MDM wins over the server channel, fix the stale getPolicyData() comment, and update the add-policy skill docs to describe native MDM > server > file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * policy: reorder selectManagedSettings params to match precedence Address PR review: make the parameter order (nativeMdm, server, file) match the resolution precedence so same-typed call sites are self-documenting and harder to transpose. Update both call sites, the unit tests, and the skill docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/add-policy/SKILL.md | 6 +++--- .../add-policy/github-managed-settings.md | 6 +++--- .../policy/common/copilotManagedSettings.ts | 18 ++++++++--------- .../common/copilotManagedSettings.test.ts | 18 ++++++++--------- .../browser/actions/developerActions.ts | 20 ++++++++++--------- .../policies/common/accountPolicyService.ts | 6 +++--- .../test/browser/accountPolicyService.test.ts | 16 +++++++-------- 7 files changed, 46 insertions(+), 44 deletions(-) diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index 1b9a1260794..7fa76a7e3a1 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -25,7 +25,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l |--------|---------------|----------------------| | **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | | **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | -| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM (`INativeManagedSettingsService`) and a file on disk (`IFileManagedSettingsService`) are **separate** inputs that `AccountPolicyService` selects among in `getPolicyData()` via `selectManagedSettings(server, nativeMdm, file)` (single authoritative source by precedence server > native MDM > file; no merging between layers) | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM (`INativeManagedSettingsService`) and a file on disk (`IFileManagedSettingsService`) are **separate** inputs that `AccountPolicyService` selects among in `getPolicyData()` via `selectManagedSettings(nativeMdm, server, file)` (single authoritative source by precedence native MDM > server > file; no merging between layers) | | **Copilot managed settings (native MDM)** | `NativeManagedSettingsService` via `@vscode/policy-watcher` | Watches `SOFTWARE\Policies\GitHubCopilot` (Windows) / `com.github.copilot` prefs (macOS); feeds the canonical `managedSettings` bag — see [github-managed-settings.md](./github-managed-settings.md) | | **Copilot managed settings (file)** | `FileManagedSettingsService` | Reads + watches `managed-settings.json` from a well-known per-OS path in the main process, exposed to renderers over IPC; lowest-precedence managed-settings channel — see [github-managed-settings.md](./github-managed-settings.md) | | **Multiplex** | `MultiplexPolicyService` | In the main process, combines multiple OS/file policy readers; in desktop and Agents-window renderers, combines the main-process `PolicyChannelClient` with `AccountPolicyService` | @@ -41,7 +41,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l | `src/vs/platform/policy/common/fileManagedSettingsService.ts` | File-based channel: reads + watches `managed-settings.json` on a well-known per-OS path, normalizes via `normalizeManagedSettings` | | `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values; parses JSON-string managed settings back to typed values; applies values to `policyReference` settings | | `src/vs/platform/configuration/common/configurationRegistry.ts` | `policy` / `policyReference` registration; `getPolicyReferenceConfigurations()` (name → subordinate settings) | -| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (server over MDM; single authoritative layer) | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (native MDM over server; single authoritative layer) | | `src/vs/workbench/services/accounts/browser/managedSettings.ts` | `adaptManagedSettings` — normalizes the server `managed_settings` response into the canonical bag | | `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | | `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | @@ -329,4 +329,4 @@ Search the codebase for `policy:` to find all the examples of different policy c * Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree. * **Regenerate `policyData.jsonc` in a clean environment, or the `PolicyExport` integration test will fail in CI.** `referencedSettings` is only captured for references **loaded at export time**. A plain `npm run export-policy-data` loads your **dev-profile extensions** (e.g. the Copilot extension), which injects `referencedSettings` onto core policies that the test's **fixture-based** export (clean profile, no user extensions) won't produce — so the checked-in file ends up with extra `referencedSettings` and CI fails. This is **not reproducible locally** because the test reuses your default extensions dir. Regenerate the way the test exports: `DISTRO_PRODUCT_JSON= ./scripts/code.sh --export-policy-data="$PWD/build/lib/policies/policyData.jsonc" --user-data-dir="$(mktemp -d)" --extensions-dir="$(mktemp -d)"` (with `VSCODE_SKIP_PRELAUNCH=1`). -* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "server-delivered managed settings win over native MDM; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. +* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "native MDM managed settings win over the server-delivered channel; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. diff --git a/.github/skills/add-policy/github-managed-settings.md b/.github/skills/add-policy/github-managed-settings.md index 2dd4735795d..fc630880223 100644 --- a/.github/skills/add-policy/github-managed-settings.md +++ b/.github/skills/add-policy/github-managed-settings.md @@ -52,8 +52,8 @@ described delivery slots (native MDM, server-managed, and file-based). All three VS Code channels converge in `AccountPolicyService.getPolicyData()`. -**Precedence: server-delivered managed settings win over native MDM, which in turn win -over the file-based channel** (`selectManagedSettings` in `copilotManagedSettings.ts`). +**Precedence: native MDM managed settings win over the server-delivered channel, which in +turn wins over the file-based channel** (`selectManagedSettings` in `copilotManagedSettings.ts`). There is a single authoritative source at any point in time — the channels are **not** merged. The highest-precedence non-empty channel wins outright and the rest are ignored. Rationale: the server is harder to bypass than local MDM, and a local file is the most @@ -201,7 +201,7 @@ delivery slot for the managed value. | `collectManagedSettingsDefinitions(policyDefinitions)` | Aggregates every policy's `managedSettings` into one `key → { type }` map. **Single source of truth** for which keys (and types) are honored; drives both the MDM watcher and the server projection. | | `hasManagedSettingsDefinitions(policyDefinitions)` | Cheap short-circuiting existence check (does *any* policy declare a managed key?) — used to decide whether the native MDM watcher is needed at all, without building the full aggregate. | | `projectManagedSettings(values, definitions, onWarn?)` | Keeps only declared keys whose runtime value **matches the declared type**. Undeclared keys and type mismatches are **dropped (validated, never coerced)**, with an optional warning. | -| `selectManagedSettings(server, nativeMdm, file)` | Picks the single authoritative channel by precedence (server → native MDM → file); never merges. **The extension point when adding a new channel** — extend the `ManagedSettingsSource` union and this function together. | +| `selectManagedSettings(nativeMdm, server, file)` | Picks the single authoritative channel by precedence (native MDM → server → file); never merges. **The extension point when adding a new channel** — extend the `ManagedSettingsSource` union and this function together. | | `managedSettingValue(key)` | Builds the standard pass-through `value` callback `policyData => policyData.managedSettings?.[key]`. Use for the common "lock to the managed value, else fall through" case (see [Declaring a managed setting](#declaring-a-managed-setting-on-a-policy)). | ### Normalization: the structured-key descriptor table diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index a0b91f1c5c7..25e35710aad 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -222,20 +222,20 @@ export interface IManagedSettingsSelection { /** * Select the authoritative managed-settings bag from the available delivery channels. * - * Precedence (highest first): server-delivered → native MDM → file on disk. The channels are + * Precedence (highest first): native MDM → server-delivered → file on disk. The channels are * never merged — managed settings have a single authoritative source, so the first non-empty bag - * wins outright. Centralizing the precedence here (rather than inlining it at each call site) - * keeps policy evaluation ({@link AccountPolicyService.getPolicyData}) and the Policy Diagnostics - * report from drifting apart, and gives one obvious place to extend when a new channel is - * introduced. + * wins outright. The parameter order matches that precedence so call sites read top-to-bottom. + * Centralizing the precedence here (rather than inlining it at each call site) keeps policy + * evaluation ({@link AccountPolicyService.getPolicyData}) and the Policy Diagnostics report from + * drifting apart, and gives one obvious place to extend when a new channel is introduced. */ -export function selectManagedSettings(server: ManagedSettingsData | undefined, nativeMdm: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsSelection { - if (server && !isEmptyObject(server)) { - return { source: 'server', values: server }; - } +export function selectManagedSettings(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsSelection { if (nativeMdm && !isEmptyObject(nativeMdm)) { return { source: 'nativeMdm', values: nativeMdm }; } + if (server && !isEmptyObject(server)) { + return { source: 'server', values: server }; + } if (file && !isEmptyObject(file)) { return { source: 'file', values: file }; } diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index 83755700f39..66b9ddb04e6 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -108,23 +108,23 @@ suite('Copilot managed settings projection', () => { assert.strictEqual(warnings.length, 1); }); - test('selectManagedSettings: server wins over native MDM and file, never merged', () => { + test('selectManagedSettings: native MDM wins over server and file, never merged', () => { const selection = selectManagedSettings( - { 'permissions.x': 'server' }, { 'permissions.y': 'native' }, + { 'permissions.x': 'server' }, { 'permissions.z': 'file' }, ); - assert.deepStrictEqual(selection, { source: 'server', values: { 'permissions.x': 'server' } }); + assert.deepStrictEqual(selection, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); }); - test('selectManagedSettings: falls through to native MDM when server is absent or empty', () => { - const fromUndefined = selectManagedSettings(undefined, { 'permissions.y': 'native' }, undefined); - const fromEmptyObject = selectManagedSettings({}, { 'permissions.y': 'native' }, undefined); - assert.deepStrictEqual(fromUndefined, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); - assert.deepStrictEqual(fromEmptyObject, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); + test('selectManagedSettings: falls through to server when native MDM is absent or empty', () => { + const fromUndefined = selectManagedSettings(undefined, { 'permissions.x': 'server' }, undefined); + const fromEmptyObject = selectManagedSettings({}, { 'permissions.x': 'server' }, undefined); + assert.deepStrictEqual(fromUndefined, { source: 'server', values: { 'permissions.x': 'server' } }); + assert.deepStrictEqual(fromEmptyObject, { source: 'server', values: { 'permissions.x': 'server' } }); }); - test('selectManagedSettings: falls through to file when server and native MDM are absent or empty', () => { + test('selectManagedSettings: falls through to file when native MDM and server are absent or empty', () => { const fromUndefined = selectManagedSettings(undefined, undefined, { 'permissions.z': 'file' }); const fromEmptyObjects = selectManagedSettings({}, {}, { 'permissions.z': 'file' }); assert.deepStrictEqual(fromUndefined, { source: 'file', values: { 'permissions.z': 'file' } }); diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 560f202f027..673663c7c5d 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -843,7 +843,7 @@ class PolicyDiagnosticsAction extends Action2 { // Reuse the same precedence as policy evaluation so this report can never drift from the // source AccountPolicyService actually applies. - const selection = selectManagedSettings(serverManagedSettings, nativeManagedSettings, fileManagedSettings); + const selection = selectManagedSettings(nativeManagedSettings, serverManagedSettings, fileManagedSettings); content += `**Active source**: ${managedSettingsSourceLabel(selection.source)}\n\n`; @@ -852,6 +852,16 @@ class PolicyDiagnosticsAction extends Action2 { // jsonc-style: accumulate every error instead of failing on the first. const parseErrors: { stage: string; message: string }[] = []; + // Sections are listed in precedence order (highest first): native MDM wins over the + // server endpoint, which in turn wins over the file on disk. + content += '### Native MDM\n\n'; + content += PROPERTY_VALUE_TABLE_HEADER; + content += `| Available | ${nativeManagedSettingsService ? 'yes' : 'no'} |\n`; + content += `| Active | ${selection.source === 'nativeMdm' ? 'yes' : 'no'} |\n\n`; + if (nativeManagedSettingsService) { + content += jsonBlock(nativeManagedSettings); + } + content += '### GitHub Server API\n\n'; content += PROPERTY_VALUE_TABLE_HEADER; content += '| Endpoint | `/copilot_internal/managed_settings` |\n'; @@ -871,14 +881,6 @@ class PolicyDiagnosticsAction extends Action2 { content += '**Normalized bag**\n\n'; content += jsonBlock(serverManagedSettings); - content += '### Native MDM\n\n'; - content += PROPERTY_VALUE_TABLE_HEADER; - content += `| Available | ${nativeManagedSettingsService ? 'yes' : 'no'} |\n`; - content += `| Active | ${selection.source === 'nativeMdm' ? 'yes' : 'no'} |\n\n`; - if (nativeManagedSettingsService) { - content += jsonBlock(nativeManagedSettings); - } - content += '### File (managed-settings.json)\n\n'; content += PROPERTY_VALUE_TABLE_HEADER; content += `| Available | ${fileManagedSettingsService ? 'yes' : 'no'} |\n`; diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index ac62b6661f8..003f6c567ed 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -190,10 +190,10 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli const nativeManagedSettings = mdmManagedSettings ?? this.nativeManagedSettingsService?.managedSettings; const fileManagedSettings = this.fileManagedSettingsService?.managedSettings; - // Single authoritative source: server-delivered managed settings win over native MDM, which - // in turn win over the file-based channel. The channels are never merged. + // Single authoritative source: native MDM managed settings win over the server-delivered + // channel, which in turn wins over the file-based channel. The channels are never merged. // See `.github/skills/add-policy/github-managed-settings.md` for the precedence rationale. - const selection = selectManagedSettings(accountPolicyData?.managedSettings, nativeManagedSettings, fileManagedSettings); + const selection = selectManagedSettings(nativeManagedSettings, accountPolicyData?.managedSettings, fileManagedSettings); if (!accountPolicyData && selection.source === 'none') { return undefined; } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index cfcca769187..01dda39bafa 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -284,9 +284,9 @@ suite('AccountPolicyService', () => { }); }); - test('managed settings: server value wins over native MDM for the same declared key', async () => { - // Server says 'enable', native MDM says 'disable'. The server is the authoritative - // source when present, so native MDM is ignored entirely and the gated policy is NOT + test('managed settings: native MDM value wins over server for the same declared key', async () => { + // Server says 'enable', native MDM says 'disable'. Native MDM is the authoritative + // source when present, so the server value is ignored entirely and the gated policy IS // forced to `false`. const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService)); @@ -300,7 +300,7 @@ suite('AccountPolicyService', () => { await policyConfiguration.initialize(); - assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); test('managed settings: native MDM applies when the server provides no managed settings', async () => { @@ -320,10 +320,10 @@ suite('AccountPolicyService', () => { assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); - test('managed settings: three-channel precedence Server > MDM > File', async () => { + test('managed settings: three-channel precedence native MDM > Server > File', async () => { // All three channels provide the same key with different values. // Server says 'enable', MDM says 'disable', File says 'file-value'. - // Server should win. + // Native MDM should win. const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value' }); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); @@ -337,8 +337,8 @@ suite('AccountPolicyService', () => { await policyConfiguration.initialize(); - // Server value 'enable' wins — policy is not forced to false - assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); + // Native MDM value 'disable' wins — policy is forced to false + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); test('managed settings: file-based settings apply when server and MDM are empty', async () => { From f292f203cb19234cceb0e0e9e3628f88e9cf0602 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:53:23 -0700 Subject: [PATCH 10/59] Migrate `chat.sendElementsToChat.attachImages` to browser settings (#323628) * Migrate `chat.sendElementsToChat.attachImages` to browser settings * Apply suggestions from code review 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> --- .../features/browserEditorChatFeatures.ts | 32 +++++++++++++++++-- .../features/browserNavigationFeatures.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 6 ---- .../preferences/browser/settingsLayout.ts | 1 - 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index ffb7e62730a..9ca63e6bb2b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -37,13 +37,19 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '. import { Registry } from '../../../../../platform/registry/common/platform.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; import { AgentHostEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { Extensions as ConfigurationMigrationExtensions, IConfigurationMigrationRegistry, workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { safeSetInnerHtml } from '../../../../../base/browser/domSanitize.js'; import { AgentHostChatToolsEnabledSettingId } from '../browserViewWorkbenchService.js'; // Register tools import '../tools/browserTools.contribution.js'; +/** + * Setting that controls whether a screenshot of the selected element is attached + * to the chat when sending elements from the Integrated Browser. + */ +const BrowserSendElementsToChatAttachImagesSettingId = 'workbench.browser.sendElementsToChat.attachImages'; + /** * Format an array of element ancestors into a CSS-selector-like path string. */ @@ -348,7 +354,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { innerText, }); - const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); + const attachImages = this.configurationService.getValue(BrowserSendElementsToChatAttachImagesSettingId); if (attachImages) { const screenshotBuffer = await model.captureScreenshot({ quality: 90, @@ -376,7 +382,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { }; type IntegratedBrowserAddElementToChatAddedClassification = { - attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; + attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether workbench.browser.sendElementsToChat.attachImages was enabled.' }; owner: 'jruales'; comment: 'An element was successfully added to chat from Integrated Browser.'; }; @@ -773,6 +779,26 @@ Registry.as(ConfigurationExtensions.Configuration).regis { comment: ['This is the description for a setting.'], key: 'browser.experimentalUserTools.enabled' }, "When enabled, experimental user-facing tools are available in the Integrated Browser's Add to Chat menu." ), + }, + [BrowserSendElementsToChatAttachImagesSettingId]: { + type: 'boolean', + default: true, + markdownDescription: localize('workbench.browser.sendElementsToChat.attachImages', "Controls whether a screenshot of the selected element will be added to the chat."), } } }); + +Registry.as(ConfigurationMigrationExtensions.ConfigurationMigration).registerConfigurationMigrations([ + { + key: 'chat.sendElementsToChat.attachImages', + migrateFn: value => { + const result: [string, { value: unknown | undefined }][] = [ + ['chat.sendElementsToChat.attachImages', { value: undefined }], + ]; + if (typeof value === 'boolean') { + result.push([BrowserSendElementsToChatAttachImagesSettingId, { value }]); + } + return result; + } + } +]); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts index c756dfa8f03..cfd3c0065da 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts @@ -539,7 +539,7 @@ class OpenBrowserSettingsAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const preferencesService = accessor.get(IPreferencesService); const contextKeyService = accessor.get(IContextKeyService); - const ids = ['workbench.browser.*', 'chat.sendElementsToChat.*']; + const ids = ['workbench.browser.*']; if (IsSessionsWindowContext.getValue(contextKeyService)) { ids.push(AgentHostChatToolsEnabledSettingId); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index ef835ec0406..9c898d51097 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -671,12 +671,6 @@ configurationRegistry.registerConfiguration({ }, } }, - 'chat.sendElementsToChat.attachImages': { - default: true, - markdownDescription: nls.localize('chat.sendElementsToChat.attachImages', "Controls whether a screenshot of the selected element will be added to the chat."), - type: 'boolean', - tags: ['experimental'] - }, [ChatConfiguration.ArtifactsEnabled]: { default: false, description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 758c8353e49..2f6f2a0612c 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -265,7 +265,6 @@ export const tocData: ITOCEntry = { 'chat.useHooks', 'chat.includeApplyingInstructions', 'chat.includeReferencedInstructions', - 'chat.sendElementsToChat.*', 'chat.useClaudeMdFile' ] }, From 3c6aa9ddaf2a1d4ae3f0c25b4f3132ee6f55015b Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:04:37 -0700 Subject: [PATCH 11/59] Fix background browser screenshots on Mac (#323637) * Fix background browser screenshots on Mac * feedback --- .../browserView/electron-main/browserView.ts | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index da4c20e34d0..b7f963076bb 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -59,6 +59,9 @@ export class BrowserView extends Disposable { private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; + private _wantsVisibility = false; + private _hasBeenLaidOut = false; + private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; private readonly _consoleLogs: string[] = []; @@ -147,7 +150,9 @@ export class BrowserView extends Disposable { }); // Use a default size of 1024x768. - this._view.setBounds({ x: -10000, y: -10000, width: 1024, height: 768 }); + // Important: The bounds here must be on-screen, otherwise some OSes (like macOS) may not actually start rendering. + // We just have to be careful to not show the view until a layout has happened in the correct location. + this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 }); this._view.setBackgroundColor('#FFFFFF'); this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; @@ -637,6 +642,11 @@ export class BrowserView extends Disposable { width: Math.round(bounds.width * bounds.zoomFactor), height: Math.round(bounds.height * bounds.zoomFactor) }); + + this._hasBeenLaidOut = true; + if (this._wantsVisibility && !this._view.getVisible()) { + this._view.setVisible(true); + } } setBrowserZoomIndex(zoomIndex: number): void { @@ -649,7 +659,7 @@ export class BrowserView extends Disposable { * Set the visibility of this view */ setVisible(visible: boolean): void { - if (this._view.getVisible() === visible) { + if (this._wantsVisibility === visible) { return; } @@ -658,7 +668,11 @@ export class BrowserView extends Disposable { this._currentWindow?.win?.webContents.focus(); } - this._view.setVisible(visible); + if (this._hasBeenLaidOut || !visible) { + this._view.setVisible(visible); + } + + this._wantsVisibility = visible; this._onDidChangeVisibility.fire({ visible }); } @@ -762,9 +776,29 @@ export class BrowserView extends Disposable { if (options?.awaitNextPaint) { await this._waitForNextPaint(); } - const image = await this._view.webContents.capturePage(options?.screenRect, { - stayHidden: true - }); + const image = await (async () => { + const maxAttempts = 5; + let lastError: Error | undefined; + for (let i = 0; i < maxAttempts; i++) { + try { + return await this._view.webContents.capturePage(options?.screenRect, { + stayHidden: true + }); + } catch (error) { + // `UnknownVizError` is a transient Electron error when no frame is available yet + // (e.g. offscreen scenarios where rendering has just been kicked off by `setVisible(true)`), + // so retry a few times. + if (error instanceof Error && error.message === 'UnknownVizError') { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 16)); + continue; + } else { + throw error; + } + } + } + throw new Error(`Failed to capture screenshot after ${maxAttempts} attempts`, { cause: lastError }); + })(); const buffer = format === 'png' ? image.toPNG() : image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); // Only update _lastScreenshot if capturing the full view From 77a5f2e4a8662bc57d470fb9981666d5cdf39fdc Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:30:58 -0700 Subject: [PATCH 12/59] Disable Responses API reasoning summaries (#323639) * Add Responses API cache control markers * Refactoring code * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Gate Responses API cache breakpoints by model support * Disable Responses API reasoning summaries * reverting cache commits * Remove Responses API cache breakpoint handling --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 13 ------------- extensions/copilot/package.nls.json | 1 - .../extension/byok/node/test/openAIEndpoint.spec.ts | 2 -- .../configuration/common/configurationService.ts | 2 -- .../src/platform/endpoint/node/responsesApi.ts | 5 +---- .../endpoint/node/test/responsesApi.spec.ts | 2 +- 6 files changed, 2 insertions(+), 23 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 2fb215472cd..f544e56638e 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4062,19 +4062,6 @@ "clear-both" ] }, - "github.copilot.chat.responsesApiReasoningSummary": { - "type": "string", - "default": "detailed", - "markdownDescription": "%github.copilot.config.responsesApiReasoningSummary%", - "tags": [ - "experimental", - "onExp" - ], - "enum": [ - "off", - "detailed" - ] - }, "github.copilot.chat.responsesApiContextManagement.enabled": { "type": "boolean", "default": false, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 0a4569e6442..b653ff34389 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -344,7 +344,6 @@ "github.copilot.config.anthropic.promptCaching.extendedTtlMessages": "Also extend the 1 hour prompt cache TTL to message-level breakpoints. Requires `chat.anthropic.promptCaching.extendedTtl` to be enabled; has no effect on its own.\n\n**Note**: This is an experimental feature.", "github.copilot.config.modelCapabilityOverrides": "Per-model capability overrides keyed by model id, intended for evaluating preview and tenanted models against an existing model's capability profile. For each model id, declare an aliased `family`. Setting `family` to a known production family (e.g. `\"claude-opus-4.7\"`) makes the model receive that family's full capability profile — Anthropic family detection, latest Opus prompt, multi-replace tools, tool search, context editing, extended cache TTL — without a code change.\n\n**Note**: This is an advanced setting for evaluation use; it is not intended for regular end-user configuration.", "github.copilot.config.useResponsesApi": "Use the Responses API instead of the Chat Completions API when supported. Enables reasoning and reasoning summaries.\n\n**Note**: This is an experimental feature that is not yet activated for all users.\n\n**Important**: For Custom Endpoint models, the API type is independent of this setting and is determined per-model via the `apiType` property, or inferred from the `url` path when omitted.", - "github.copilot.config.responsesApiReasoningSummary": "Sets the reasoning summary style used for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApiContextManagement.enabled": "Enables context management for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApi.promptCacheKey.enabled": "Enables prompt cache key being set for the Responses API.", "github.copilot.config.responsesApi.persistentCoT.enabled": "Enables persistent chain of thought for supported Responses API models.", diff --git a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts index b9b9eed5184..0f1c081448e 100644 --- a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts +++ b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts @@ -246,7 +246,6 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { describe('Responses API mode (useResponsesApi = true)', () => { it('should preserve reasoning object when thinking is supported', () => { - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); const endpoint = instaService.createInstance(OpenAIEndpoint, modelMetadata, 'test-api-key', @@ -275,7 +274,6 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { } }; - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); const endpoint = instaService.createInstance(OpenAIEndpoint, modelWithoutThinking, 'test-api-key', diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 405c7ca13ee..51f69b66e52 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -973,8 +973,6 @@ export namespace ConfigKey { export const UseAnthropicMessagesApi = defineSetting('chat.anthropic.useMessagesApi', ConfigType.ExperimentBased, true); /** Context editing mode for Anthropic Messages API. 'off' disables context editing. */ export const AnthropicContextEditingMode = defineSetting<'off' | 'clear-thinking' | 'clear-tooluse' | 'clear-both'>('chat.anthropic.contextEditing.mode', ConfigType.ExperimentBased, 'off'); - /** Configure reasoning summary style sent to Responses API */ - export const ResponsesApiReasoningSummary = defineSetting<'off' | 'detailed'>('chat.responsesApiReasoningSummary', ConfigType.ExperimentBased, 'detailed'); /** Enable context_management sent to Responses API */ export const ResponsesApiContextManagementEnabled = defineSetting('chat.responsesApiContextManagement.enabled', ConfigType.ExperimentBased, false); /** Enable client-side prompt_cache_key (conversationId:modelFamily) sent to Responses API */ diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index fba5bbff3f7..23f52666d9f 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -155,14 +155,11 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: body.truncation = configService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation) ? 'auto' : 'disabled'; - const thinkingExplicitlyDisabled = options.modelCapabilities?.enableThinking === false; - const summaryConfig = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiReasoningSummary, expService); - const shouldDisableReasoningSummary = endpoint.family === 'gpt-5.3-codex-spark-preview' || thinkingExplicitlyDisabled; const effortFromSetting = configService.getConfig(ConfigKey.Advanced.ReasoningEffortOverride); const effort = endpoint.supportsReasoningEffort?.length ? (effortFromSetting || options.modelCapabilities?.reasoningEffort || 'medium') : undefined; - const summary = summaryConfig === 'off' || shouldDisableReasoningSummary ? undefined : summaryConfig; + const summary = 'off'; const persistentCoTEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, expService) && (isGpt54(endpoint) || isGpt55(endpoint) || isHiddenModelM(endpoint)); if (effort || summary || persistentCoTEnabled) { diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index c8bd81d3882..5dc8e5a8ab6 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -378,7 +378,7 @@ describe('createResponsesRequestBody', () => { const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), endpoint.model, endpoint)); - expect(body.reasoning).toEqual({ effort: 'medium', summary: 'detailed', context: 'all_turns' }); + expect(body.reasoning).toEqual({ effort: 'medium', summary: 'off', context: 'all_turns' }); accessor.dispose(); services.dispose(); From 028d3cab28890761172f3bcd7e92b6de73846fb4 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Mon, 29 Jun 2026 17:31:57 -0700 Subject: [PATCH 13/59] Fix potential memory leak in terminal link hover (#323629) * Fix potential memory leak in terminal link hover * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Leave comment about osc 8 hovers * Handle OSC 8 terminal hover cleanup --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Anthony Kim --- .../links/browser/terminalLinkManager.ts | 12 ++++-- .../test/browser/terminalLinkManager.test.ts | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts index 3e7002c6980..9b0622ddf75 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts @@ -5,7 +5,7 @@ import { EventType } from '../../../../../base/browser/dom.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { DisposableStore, dispose, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { isMacintosh, OS } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import * as nls from '../../../../../nls.js'; @@ -47,6 +47,7 @@ export class TerminalLinkManager extends DisposableStore { private readonly _linkProvidersDisposables: IDisposable[] = []; private readonly _externalLinkProviders: IDisposable[] = []; private readonly _openers: Map = new Map(); + private readonly _linkHoverInvalidationDisposable = this.add(new MutableDisposable()); externalProvideLinksCb?: OmitFirstArg; @@ -405,9 +406,14 @@ export class TerminalLinkManager extends DisposableStore { const widget = this._instantiationService.createInstance(TerminalHover, targetOptions, text, actions, linkHandler); const attached = this._widgetManager.attachWidget(widget); if (attached) { - link?.onInvalidated(() => attached.dispose()); + const store = new DisposableStore(); + store.add(attached); + if (link) { + store.add(link.onInvalidated(() => store.dispose())); + } + this._linkHoverInvalidationDisposable.value = store; + return store; } - return attached; } return undefined; } diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts index 51a7e4fbb81..7132cb0d596 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts @@ -30,6 +30,12 @@ import { TestXtermLogger } from '../../../../../../platform/terminal/test/common import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { timeout } from '../../../../../../base/common/async.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { ILinkHoverTargetOptions } from '../../../../terminal/browser/widgets/terminalHoverWidget.js'; +import { TerminalWidgetManager } from '../../../../terminal/browser/widgets/widgetManager.js'; +import { TerminalLink } from '../../browser/terminalLink.js'; const defaultTerminalConfig: Partial = { fontFamily: 'monospace', @@ -221,6 +227,43 @@ suite('TerminalLinkManager', () => { })); }); + suite('link hover invalidation', () => { + type ShowHover = ( + targetOptions: ILinkHoverTargetOptions, + text: MarkdownString, + actions: undefined, + linkHandler: (url: string) => void, + link?: TerminalLink + ) => IDisposable | undefined; + + test('replacing or invalidating a link hover disposes the previous hover and its invalidation listener', () => { + instantiationService.stub(IHoverService, upcastPartial({})); + + // Fake widget manager that records disposal of each attached hover and disposes the widget + const disposedAttached: boolean[] = []; + linkManager.setWidgetManager(upcastPartial({ + attachWidget: widget => { + const index = disposedAttached.push(false) - 1; + return { dispose: () => { disposedAttached[index] = true; widget.dispose(); } }; + } + })); + + const showHover = (linkManager as unknown as { _showHover: ShowHover })._showHover.bind(linkManager); + const onInvalidated1 = store.add(new Emitter()); + const onInvalidated2 = store.add(new Emitter()); + const link1 = upcastPartial({ onInvalidated: onInvalidated1.event }); + const link2 = upcastPartial({ onInvalidated: onInvalidated2.event }); + const targetOptions = upcastPartial({}); + + // Showing a second link hover should dispose the first, then invalidating the second disposes it + showHover(targetOptions, new MarkdownString('hover'), undefined, () => { }, link1); + showHover(targetOptions, new MarkdownString('hover'), undefined, () => { }, link2); + onInvalidated2.fire(); + + deepStrictEqual(disposedAttached, [true, true]); + }); + }); + suite('getLinks and open recent link', () => { test('should return no links', async () => { const links = await linkManager.getLinks(); From cdc1a322ff9b241f890443633ae3b03475f0a93c Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Mon, 29 Jun 2026 18:58:06 -0700 Subject: [PATCH 14/59] Hide Local from Continue In list of session types (#323649) --- .../widget/input/delegationSessionPickerActionItem.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 5ec3916c821..7ee3dd19467 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -21,6 +21,7 @@ import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { isVisibleEditorChatSessionType } from '../../../common/constants.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentCanContinueIn, getAgentSessionProvider, isAgentHostTarget, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; @@ -124,6 +125,11 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return false; } + // Apply the same visibility guards as the new-session picker (e.g. Local hidden when chat.editor.localAgent.enabled is false). + if (!isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService)) { + return false; + } + return getAgentCanContinueIn(type); } From 02a5fec726ee9585f33182d9885581bc984ad5a9 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:58:09 -0700 Subject: [PATCH 15/59] Fix restored Agent Host workspace context (#323648) Preserve workspace context as a hidden workspace variable when Agent Host session history is restored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionHandler.ts | 2 +- .../agentHost/stateToProgressAdapter.ts | 9 +++++ .../agentHostChatContribution.test.ts | 40 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 66ca585e3ce..5162e8aa433 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3655,7 +3655,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return this._toSimpleAttachment(v.name, v.value, v._meta, undefined, referenceRange); } if (v.kind === 'workspace') { - return this._toSimpleAttachment(v.name, v.value, v._meta, undefined, referenceRange); + return this._toSimpleAttachment(v.name, v.value, v._meta, 'workspace', referenceRange); } if (v.kind === 'string' && typeof v.value === 'string') { return this._toSimpleAttachment(v.name, v.value, v._meta, undefined, referenceRange); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index bc150f3d417..3d52cdaaf3c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -569,6 +569,15 @@ function messageAttachmentToVariableEntry(attachment: MessageAttachment, connect } const modelRepresentation = attachment.type === MessageAttachmentKind.Simple ? attachment.modelRepresentation : undefined; + if (attachment.displayKind === 'workspace' && modelRepresentation !== undefined) { + return { + kind: 'workspace', + id: attachment.label, + name: attachment.label, + value: modelRepresentation, + _meta: attachment._meta, + }; + } const pasteEntry = restorePasteVariableEntryFromAttachment({ label: attachment.label, displayKind: attachment.displayKind, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 68b4643ebc9..c0ee99ed879 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -984,6 +984,46 @@ suite('AgentHostChatContribution', () => { await turnPromise; }); + test('preserves workspace context as a hidden workspace variable on history replay', async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'continue', + variables: { + variables: [{ + kind: 'workspace', + id: 'workspace', + name: 'workspace', + value: 'Workspace context', + }], + }, + }); + + const turnStarted = agentHostService.turnActions[0].action as ITurnStartedAction; + const attachments = turnStarted.message.attachments; + const replayedVariables = messageAttachmentsToVariableData(attachments, 'test')?.variables; + assert.deepStrictEqual({ + attachments, + replayedVariables, + }, { + attachments: [{ + type: MessageAttachmentKind.Simple, + label: 'workspace', + modelRepresentation: 'Workspace context', + displayKind: 'workspace', + }], + replayedVariables: [{ + kind: 'workspace', + id: 'workspace', + name: 'workspace', + value: 'Workspace context', + _meta: undefined, + }], + }); + + fire({ type: 'chat/turnComplete', session: session!, turnId: turnId! } as ChatAction); + await turnPromise; + }); + test('sends agent feedback variables as annotations attachments referencing each comment', async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/feedback-send' }); From b7f08a3026d20ed5159534a4d77ddf05d3bf87f7 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 29 Jun 2026 18:58:48 -0700 Subject: [PATCH 16/59] chat: avoid layout shift when switching sessions (#323383) * chat: avoid layout shift when switching sessions Publish every fresh dynamic row measurement to the delegate cache so chat can size the last response correctly before the initial ResizeObserver report. Preserve the deferred initial height-change behavior and avoid any additional DOM measurement.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * list: publish only valid dynamic heights Cover delegate-provided dynamic heights and centralize publication so all fresh probe paths update caches while zero heights remain excluded.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/list/list.ts | 4 ++ src/vs/base/browser/ui/list/listView.ts | 16 +++-- .../test/browser/ui/list/listView.test.ts | 65 ++++++++++++++++++- .../chat/browser/widget/chatListRenderer.ts | 4 ++ .../chat/browser/widget/chatListWidget.ts | 13 +++- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/vs/base/browser/ui/list/list.ts b/src/vs/base/browser/ui/list/list.ts index 3e7cb017967..0d3c7f995b7 100644 --- a/src/vs/base/browser/ui/list/list.ts +++ b/src/vs/base/browser/ui/list/list.ts @@ -151,6 +151,10 @@ export abstract class CachedListVirtualDelegate implements ILi return this.cache.get(element) ?? this.estimateHeight(element); } + protected getCachedHeight(element: T): number | undefined { + return this.cache.get(element); + } + protected abstract estimateHeight(element: T): number; abstract getTemplateId(element: T): string; diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 5c62e99faf8..831e610665e 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -1620,12 +1620,7 @@ export class ListView implements IListView { private probeDynamicHeight(index: number): number { const item = this.items[index]; - const diff = this.probeDynamicHeightForItem(item, index); - if (diff > 0) { - this.virtualDelegate.setDynamicHeight?.(item.element, item.size); - } - - return diff; + return this.probeDynamicHeightForItem(item, index); } private probeDynamicHeightForItem(item: IItem, index: number): number { @@ -1635,6 +1630,7 @@ export class ListView implements IListView { const size = item.size; item.size = newSize; item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return newSize - size; } } @@ -1660,6 +1656,7 @@ export class ListView implements IListView { } } item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return item.size - size; } @@ -1678,12 +1675,19 @@ export class ListView implements IListView { renderer.disposeElement?.(item.element, index, row.templateData); item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); row.domNode.remove(); this.cache.release(row); return item.size - size; } + private publishDynamicHeight(item: IItem): void { + if (item.size > 0) { + this.virtualDelegate.setDynamicHeight?.(item.element, item.size); + } + } + getElementDomId(index: number): string { return `${this.domId}_${index}`; } diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 37426c29206..6b936d7823f 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; +import { CachedListVirtualDelegate, IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; import { ListView } from '../../../../browser/ui/list/listView.js'; import { range } from '../../../../common/arrays.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; @@ -40,4 +40,67 @@ suite('ListView', function () { listView.dispose(); assert.strictEqual(templatesCount, 0, 'all templates have been disposed'); }); + + test('publishes freshly measured dynamic heights', function () { + const element = document.createElement('div'); + element.style.height = '200px'; + element.style.width = '200px'; + document.body.appendChild(element); + + type TestElement = { height: number }; + const delegate = new class extends CachedListVirtualDelegate { + protected estimateHeight() { return 100; } + getTemplateId() { return 'template'; } + hasDynamicHeight() { return true; } + getMeasuredHeight(element: TestElement) { return this.getCachedHeight(element); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate(container) { + const content = document.createElement('div'); + container.appendChild(content); + return content; + }, + renderElement(element, _index, templateData) { templateData.style.height = `${element.height}px`; }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(element, delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(200, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => delegate.getMeasuredHeight(element)), [40, 100, 160]); + } finally { + listView.dispose(); + element.remove(); + } + }); + + test('publishes positive delegate-provided dynamic heights', function () { + type TestElement = { height: number }; + const publishedHeights = new Map(); + const delegate: IListVirtualDelegate = { + getHeight() { return 100; }, + getTemplateId() { return 'template'; }, + getDynamicHeight(element) { return element.height; }, + setDynamicHeight(element, height) { publishedHeights.set(element, height); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate() { }, + renderElement() { }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 0 }, { height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(400, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => publishedHeights.get(element)), [undefined, 40, 100, 160]); + } finally { + listView.dispose(); + } + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 66e50b5272a..0ba3e281517 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -3592,6 +3592,10 @@ export class ChatListDelegate extends CachedListVirtualDelegate { hasDynamicHeight(element: ChatTreeItem): boolean { return true; } + + getMeasuredHeight(element: ChatTreeItem): number | undefined { + return this.getCachedHeight(element); + } } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index ca65a77adc1..be3ccc93cbb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -176,6 +176,7 @@ export class ChatListWidget extends Disposable { //#region Private fields private readonly _tree: WorkbenchObjectTree; + private readonly _delegate: ChatListDelegate; private readonly _renderer: ChatListItemRenderer; private _viewModel: IChatViewModel | undefined; @@ -297,7 +298,7 @@ export class ChatListWidget extends Disposable { )); // Create delegate - const delegate = scopedInstantiationService.createInstance( + this._delegate = scopedInstantiationService.createInstance( ChatListDelegate, options.defaultElementHeight ?? 200 ); @@ -364,7 +365,7 @@ export class ChatListWidget extends Disposable { WorkbenchObjectTree, 'ChatList', this._container, - delegate, + this._delegate, [this._renderer], { identityProvider: { getId: (e: ChatTreeItem) => e.id }, @@ -531,6 +532,8 @@ export class ChatListWidget extends Disposable { const items = this._viewModel.getItems(); this._lastItem = items.at(-1); this._lastItemIdContextKey.set(this._lastItem ? [this._lastItem.id] : []); + const previousItem = items.at(-2); + const needsInitialPreviousItemHeight = (isRequestVM(previousItem) || isResponseVM(previousItem)) && previousItem.currentRenderedHeight === undefined; const treeItems: ITreeElement[] = items.map(item => ({ element: item, @@ -572,6 +575,10 @@ export class ChatListWidget extends Disposable { } }); }); + + if (needsInitialPreviousItemHeight) { + this.updateLastItemMinHeight(); + } } /** @@ -876,7 +883,7 @@ export class ChatListWidget extends Disposable { const maxRequestShownHeight = 200; const secondToLastItemHeight = Math.min( (isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem)) ? - secondToLastItem.currentRenderedHeight ?? 150 : 150, + secondToLastItem.currentRenderedHeight ?? this._delegate.getMeasuredHeight(secondToLastItem) ?? 150 : 150, maxRequestShownHeight); const lastItemMinHeight = Math.max(contentHeight - (secondToLastItemHeight + 10), 0); this._container.style.setProperty('--chat-current-response-min-height', lastItemMinHeight + 'px'); From fc8ff1bbb0097577892561cc0191284462c539c7 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:01:27 -0700 Subject: [PATCH 17/59] Tidy Copilot managed-settings structured table for consistency (#323613) * Tidy Copilot managed-settings structured table for consistency Give every STRUCTURED_MANAGED_SETTINGS row a named encode* helper (encodeObject, encodeArray, encodeExtraMarketplaces) co-located with the existing encodeStringMap, instead of mixing inline lambdas with named refs. This keeps the central deserialization/mapping table uniform and easy to extend. No behavior change. base/common/managedSettings.ts stays the shared base-layer module (consumed by both the policy layer and chat plugin code), and IStrictMarketplaceSource is retained. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden extraKnownMarketplacesToConfigDict against prototype pollution Marketplace names arrive from managed settings (untrusted input) and are written as object keys, so skip __proto__/constructor/prototype keys, mirroring the guard already present in the managed-settings string-map encoder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Centralize the default-model managed-settings value callback The model managed setting was the only managed-settings-driven control whose policy value callback was hand-rolled inline in chat.shared.contribution.ts (pass-through + trim + empty->undefined), while enabledPlugins / extraMarketplaces / strictMarketplaces all use the shared managedSettingValue() helper. Add a memoized managedModelValue() helper in copilotManagedSettings.ts that holds the model-specific trim/empty normalization, and wire the policy with value: managedModelValue() so every managed-settings control is declared the same way and the model normalization lives with the rest of the managed-settings logic. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/common/managedSettings.ts | 15 ++++++ .../policy/common/copilotManagedSettings.ts | 50 +++++++++++++++++-- .../chat/browser/chat.shared.contribution.ts | 8 +-- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/vs/base/common/managedSettings.ts b/src/vs/base/common/managedSettings.ts index 56e99e0ef0b..1d5205a0b99 100644 --- a/src/vs/base/common/managedSettings.ts +++ b/src/vs/base/common/managedSettings.ts @@ -38,6 +38,10 @@ export interface IStrictMarketplaceSource { * * Plain-string entries (allowed by the policy schema but unnamed) are stored with * the value used as both key and value so they survive the round-trip intact. + * + * Marketplace names come from managed settings (untrusted input) and are written as object keys, + * so `__proto__` / `constructor` / `prototype` keys are skipped to avoid prototype pollution + * (mirroring the guard in the managed-settings normalizer's string-map encoder). */ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | IExtraKnownMarketplaceEntry)[] | undefined): Record | undefined { if (!entries?.length) { @@ -46,8 +50,14 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I const obj: Record = {}; for (const entry of entries) { if (typeof entry === 'string') { + if (isUnsafeMarketplaceKey(entry)) { + continue; + } obj[entry] = entry; } else { + if (isUnsafeMarketplaceKey(entry.name)) { + continue; + } const s = entry.source; const base = s.source === 'github' ? s.repo : s.url; obj[entry.name] = s.ref ? `${base}#${s.ref}` : base; @@ -55,3 +65,8 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I } return obj; } + +/** Whether a marketplace name would pollute the prototype chain if used as an object key. */ +function isUnsafeMarketplaceKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 25e35710aad..69b02bc6d17 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -93,6 +93,31 @@ export function managedSettingValue(key: string): (policyData: IPolicyData) => M return callback; } +let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined; + +/** + * `value` callback for the default-chat-model managed setting ({@link COPILOT_MODEL_KEY}). Like + * {@link managedSettingValue} it locks the setting to the managed value and otherwise falls through + * to the user's own value, but it additionally trims the string and treats a blank/whitespace-only + * value as "unset" (returns `undefined`) — an admin clearing the field must not lock the setting to + * an empty string. The model-specific normalization lives here, alongside the other managed-settings + * handling, rather than inline at the policy declaration, so every managed-settings control is wired + * the same way. + * + * Memoized (single key) so repeated calls return the SAME function reference, matching the + * reference-identity contract {@link managedSettingValue} relies on for `isSamePolicyDefinition`. + */ +export function managedModelValue(): (policyData: IPolicyData) => ManagedSettingValue | undefined { + if (!managedModelValueCallback) { + managedModelValueCallback = policyData => { + const model = policyData.managedSettings?.[COPILOT_MODEL_KEY]; + const trimmed = typeof model === 'string' ? model.trim() : undefined; + return trimmed ? trimmed : undefined; + }; + } + return managedModelValueCallback; +} + export const INativeManagedSettingsService = createDecorator('nativeManagedSettingsService'); export interface INativeManagedSettingsService { @@ -301,18 +326,37 @@ function encodeStringMap(value: unknown): Record | undefined { return out; } +/** Pass an object value through unchanged; omit the key for any non-object value. */ +function encodeObject(value: unknown): object | undefined { + return isObject(value) ? value : undefined; +} + +/** Pass an array value through unchanged (including an empty array); omit the key otherwise. */ +function encodeArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +/** + * Encode the schema's `{ [id]: { source } }` marketplace map into the canonical + * `{ [name]: url-or-shorthand }` dict; drops malformed entries (with an optional warning) and omits + * the key when there are none. + */ +function encodeExtraMarketplaces(value: unknown, onWarn?: (msg: string) => void): Record | undefined { + return extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)); +} + const STRUCTURED_MANAGED_SETTINGS: readonly IStructuredManagedSetting[] = [ { key: COPILOT_ENABLED_PLUGINS_KEY, - encode: value => isObject(value) ? value : undefined, + encode: encodeObject, }, { key: COPILOT_STRICT_MARKETPLACES_KEY, - encode: value => Array.isArray(value) ? value : undefined, + encode: encodeArray, }, { key: COPILOT_EXTRA_MARKETPLACES_KEY, - encode: (value, onWarn) => extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)), + encode: encodeExtraMarketplaces, }, { // Nested under `telemetry`; carried as a JSON-encoded `{ [k]: string }` map. Non-string diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 9c898d51097..b79ef273ace 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -15,7 +15,7 @@ import '../../../../platform/agentHost/common/agentHostStarter.config.contributi import { AgentHostAhpJsonlLoggingSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostSdkSandboxEnabledSettingId, ClaudePreferAgentHostAgentsSettingId, ClaudePreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; @@ -538,11 +538,7 @@ configurationRegistry.registerConfiguration({ name: 'ChatDefaultModel', category: PolicyCategory.InteractiveSession, minimumVersion: '1.127', - value: (policyData) => { - const model = policyData.managedSettings?.[COPILOT_MODEL_KEY]; - const trimmed = typeof model === 'string' ? model.trim() : undefined; - return trimmed ? trimmed : undefined; - }, + value: managedModelValue(), managedSettings: { [COPILOT_MODEL_KEY]: { type: 'string' }, }, From 47af1fb17e5bc515f01ef051edc14bc6744780cb Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Mon, 29 Jun 2026 23:13:58 -0700 Subject: [PATCH 18/59] Rename capi-oauth secret (#323667) Rename capi-oauth secret to capi-oauth-pipeline-token The capi-oauth Key Vault secret value leaked. Rename the secret reference to capi-oauth-pipeline-token so that revoking the old capi-oauth secret cuts off access for any other harness still pointing at the old name. The new name is also more descriptive of its purpose (CAPI OAuth token used in the automation pipeline). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/script/setup/getEnv.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/copilot/script/setup/getEnv.mts b/extensions/copilot/script/setup/getEnv.mts index 0cd82c9a737..dc11f51a84d 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -57,7 +57,7 @@ async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { secrets['HMAC_SECRET'] = await fetchSecret(keyVaultClient, 'hmac-secret'); if (!process.stdin.isTTY) { // only in automation - secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth'); + secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth-pipeline-token'); secrets['VSCODE_COPILOT_CHAT_TOKEN'] = await fetchSecret(keyVaultClient, 'copilot-token'); secrets['BLACKBIRD_EMBEDDINGS_KEY'] = await fetchSecret(keyVaultClient, 'vsc-aoai-key'); secrets['BLACKBIRD_REDIS_CACHE_KEY'] = await fetchSecret(keyVaultClient, 'blackbird-redis-cache-key'); From f1342801178aa38bfeec106ac4b55e00f914f0b0 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:45:53 +0000 Subject: [PATCH 19/59] Agents - select the first available changeset when default is not present (#323671) * Agents - select the first available changeset when default is not present * Pull request feedback --- .../contrib/changes/browser/changesViewService.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 032811d7c3a..eafd1556e23 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -108,13 +108,24 @@ export class ChangesViewService extends Disposable implements IChangesViewServic const activeSessionChangesets = this.activeSessionChangesetsObs.read(reader) ?? []; // Honor an explicit selection only while it is still enabled; otherwise fall - // back to the default changeset so the picker never shows a disabled selection. + // back to the default, first enabled changeset so the picker never shows a + // disabled selection. const selectedChangeset = selectedChangesetId ? activeSessionChangesets .find(c => c.id === selectedChangesetId && c.isEnabled.read(reader)) : undefined; - return selectedChangeset ?? activeSessionChangesets.find(c => c.isDefault.read(reader)); + if (selectedChangeset) { + return selectedChangeset; + } + + const defaultChangeset = activeSessionChangesets + .find(c => c.isDefault.read(reader)); + + const firstEnabledChangeset = activeSessionChangesets + .find(c => c.isEnabled.read(reader)); + + return defaultChangeset ?? firstEnabledChangeset; }); this.activeSessionChangesetOperationsObs = derived(reader => { From 5efc0b7cadaa7415d9bf6aac653274f41d785e89 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:46:11 -0700 Subject: [PATCH 20/59] fix AH code blocks (#323665) --- .../widget/chatContentParts/media/chatCodeBlockPill.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css index c81201545fc..83c91b6588e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css @@ -7,6 +7,10 @@ margin-bottom: 16px; } +.interactive-item-container.editing-session.interactive-response > .value > .chat-codeblock-pill-container { + margin-bottom: 14px; +} + .chat-codeblock-pill-container { display: flex; align-items: center; From cb39ed9e9bb49ccc5b1c285054a764389df12dab Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:33:38 -0700 Subject: [PATCH 21/59] AH: fix subagent rendering and tools not attaching (#323638) * AH: fix subagent rendering and tools not attaching * address comments --- .../agentHost/agentHostSessionHandler.ts | 29 +++++- .../agentHost/stateToProgressAdapter.ts | 5 +- .../chatSubagentContentPart.ts | 78 +++++++++------ .../chat/browser/widget/chatListRenderer.ts | 95 +++++++++++++------ .../chat/common/chatService/chatService.ts | 1 + .../agentHostChatContribution.test.ts | 78 +++++++++++++-- .../stateToProgressAdapter.test.ts | 9 +- .../chatSubagentContentPart.test.ts | 62 ++++++++++-- .../browser/widget/chatListRenderer.test.ts | 38 +++++++- 9 files changed, 315 insertions(+), 80 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 5162e8aa433..09634c19d9c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2066,6 +2066,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return; } subagentContext.observedToolIds.add(toolCallId); + if (invocation.toolSpecificData?.kind === 'subagent') { + invocation.toolSpecificData.isActive = true; + invocation.notifyToolSpecificDataChanged(); + } // Track this subagent's own running credit (AIC) total so it can be // surfaced on the subagent tool's hover and persisted via its @@ -2092,7 +2096,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } })); - this._observeSubagentSession(opts.sessionResource, opts.backendSession, toolCallId, opts.sink, store, subagentContext, perInvocationCredits, perInvocationModel); + this._observeSubagentSession(opts.sessionResource, opts.backendSession, toolCallId, invocation, opts.sink, store, subagentContext, perInvocationCredits, perInvocationModel); }; // Initial confirmation hookup. The autorun below only handles @@ -2137,6 +2141,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC updateRunningToolSpecificData(invocation, tc, opts.backendSession, this._config.connectionAuthority); } + tryObserveSubagent(tc); + if ((status === ToolCallStatus.Completed || status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(invocation)) { // Revive terminal before finalizing — handles the case where // Running was skipped (e.g. throttling) and terminal content @@ -2147,8 +2153,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC opts.onFileEdits?.(tc, fileEdits); } } - - tryObserveSubagent(tc); })); // If the turn ends with the tool still mid-flight (e.g. external @@ -2204,6 +2208,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const invocation = this._toolsService.beginToolCall({ toolCallId, toolId: toolData.id, + subagentInvocationId: opts.subAgentInvocationId, sessionResource: opts.sessionResource, force: true, }) as ChatToolInvocation | undefined; @@ -2944,6 +2949,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC sessionResource: URI, parentSession: URI, parentToolCallId: string, + parentInvocation: ChatToolInvocation, emitProgress: (parts: IChatProgress[]) => void, disposables: DisposableStore, subagentContext: ISubagentContext, @@ -2955,6 +2961,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const cts = new CancellationTokenSource(); disposables.add(toDisposable(() => cts.dispose(true))); + disposables.add(toDisposable(() => { + if (parentInvocation.toolSpecificData?.kind === 'subagent' && parentInvocation.toolSpecificData.isActive) { + parentInvocation.toolSpecificData.isActive = false; + parentInvocation.notifyToolSpecificDataChanged(); + } + })); try { const childSub = this._ensureSessionSubscription(parentSessionUri); @@ -2970,6 +2982,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } return mergeSessionWithDefaultChat(session, childChatState$.read(reader)); }); + disposables.add(autorun(reader => { + const state = childState$.read(reader); + if (!state || (!state.activeTurn && state.turns.length === 0)) { + return; + } + const isActive = !!state.activeTurn; + if (parentInvocation.toolSpecificData?.kind === 'subagent' && parentInvocation.toolSpecificData.isActive !== isActive) { + parentInvocation.toolSpecificData.isActive = isActive; + parentInvocation.notifyToolSpecificDataChanged(); + } + })); const childTurnIds$ = derived(reader => { const state = childState$.read(reader); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 3d52cdaaf3c..6ca75cf37c4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -1353,6 +1353,7 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: if (subagentContent) { existing.toolSpecificData = { kind: 'subagent', + isActive: existing.toolSpecificData?.kind === 'subagent' ? existing.toolSpecificData.isActive : undefined, description: getSubagentTaskDescription(tc), agentName: subagentContent.agentName, credits: existing.toolSpecificData?.kind === 'subagent' ? existing.toolSpecificData.credits : undefined, @@ -1370,7 +1371,7 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: const description = getSubagentTaskDescription(tc) ?? existing.toolSpecificData.description; const agentName = getSubagentAgentName(tc) ?? existing.toolSpecificData.agentName; if (description !== existing.toolSpecificData.description || agentName !== existing.toolSpecificData.agentName) { - existing.toolSpecificData = { kind: 'subagent', description, agentName, credits: existing.toolSpecificData.credits, modelName: existing.toolSpecificData.modelName }; + existing.toolSpecificData = { kind: 'subagent', isActive: existing.toolSpecificData.isActive, description, agentName, credits: existing.toolSpecificData.credits, modelName: existing.toolSpecificData.modelName }; existing.notifyToolSpecificDataChanged(); } return; @@ -1441,6 +1442,7 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC const resultText = getToolOutputText(tc); invocation.toolSpecificData = { kind: 'subagent', + isActive: invocation.toolSpecificData?.kind === 'subagent' ? invocation.toolSpecificData.isActive : undefined, description: getSubagentTaskDescription(tc), agentName: subagentContent.agentName, result: resultText, @@ -1452,6 +1454,7 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC // block. Refresh metadata + carry the tool's output as the result. invocation.toolSpecificData = { kind: 'subagent', + isActive: invocation.toolSpecificData.isActive, description: getSubagentTaskDescription(tc) ?? invocation.toolSpecificData.description, agentName: getSubagentAgentName(tc) ?? invocation.toolSpecificData.agentName, result: getToolOutputText(tc), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 7f479c48940..cea6426f027 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -83,7 +83,8 @@ type ILazyItem = ILazyToolItem | ILazyMarkdownItem | ILazyHookItem; */ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implements IChatContentPart { private wrapper!: HTMLElement; - private isActive: boolean = true; + private isActive: boolean; + private isExternallyActive: boolean; private hasToolItems: boolean = false; private readonly isInitiallyComplete: boolean; private promptContainer: HTMLElement | undefined; @@ -216,17 +217,21 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.prompt = prompt; this.modelName = modelName; this.credits = credits; - this.isInitiallyComplete = this.element.isComplete; + this.isInitiallyComplete = IChatToolInvocation.isComplete(toolInvocation); + this.isExternallyActive = toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.isActive === true; + this.isActive = toolInvocation.toolSpecificData?.kind === 'subagent' + ? toolInvocation.toolSpecificData.isActive ?? !this.isInitiallyComplete + : !this.isInitiallyComplete; const node = this.domNode; node.classList.add('chat-thinking-box', 'chat-thinking-fixed-mode', 'chat-subagent-part'); - if (!this.element.isComplete) { + if (this.isActive) { node.classList.add('chat-thinking-active'); } // Apply shimmer to the initial title when still active - if (!this.element.isComplete && this._collapseButton) { + if (this.isActive && this._collapseButton) { const labelElement = this._collapseButton.labelElement; labelElement.textContent = ''; this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); @@ -236,14 +241,14 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Note: wrapper is created lazily in initContent(), so we can't set its style here - if (this._collapseButton && !this.element.isComplete) { + if (this._collapseButton && this.isActive) { this._collapseButton.icon = Codicon.circleFilled; } this._register(autorun(r => { this.expanded.read(r); if (this._collapseButton) { - if (!this.element.isComplete && this.isActive) { + if (this.isActive) { this._collapseButton.icon = Codicon.circleFilled; } else { this._collapseButton.icon = Codicon.check; @@ -429,6 +434,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen return this.isActive; } + public shouldRemainActive(): boolean { + return this.isExternallyActive; + } + public get hasToolsWaitingForConfirmation(): boolean { return this.toolsWaitingForConfirmation > 0; } @@ -473,6 +482,33 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.setExpanded(false); } + private markAsActive(): void { + if (this.isActive) { + return; + } + this.isActive = true; + this.domNode.classList.add('chat-thinking-active'); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.circleFilled; + } + if (this.wrapper && !this.hasToolsWaitingForConfirmation) { + this.showWorkingSpinner(); + } + this.updateTitle(); + } + + private refreshActiveStateFromToolData(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (toolInvocation.toolSpecificData?.kind !== 'subagent' || toolInvocation.toolSpecificData.isActive === undefined) { + return; + } + this.isExternallyActive = toolInvocation.toolSpecificData.isActive; + if (toolInvocation.toolSpecificData.isActive) { + this.markAsActive(); + } else { + this.markAsInactive(); + } + } + public finalizeTitle(): void { this.updateTitle(); if (this._collapseButton) { @@ -763,6 +799,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen let wasStreaming = toolInvocation.state.get().type === IChatToolInvocation.StateKind.Streaming; this._register(autorun(r => { const state = toolInvocation.state.read(r); + this.refreshActiveStateFromToolData(toolInvocation); if (state.type === IChatToolInvocation.StateKind.Completed) { wasStreaming = false; // Extract text from result @@ -789,8 +826,9 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // subagent's child turns report their final usage. this.refreshCreditsFromToolData(toolInvocation); - // Mark as inactive when the tool completes - this.markAsInactive(); + if (!this.isExternallyActive) { + this.markAsInactive(); + } } else if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { wasStreaming = false; // Update things that change when tool is done streaming @@ -1227,26 +1265,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { - if (other.kind === 'markdownContent') { - return true; - } - - // Match hook parts with the same subAgentInvocationId to keep them grouped in the subagent dropdown - if (other.kind === 'hook' && other.subAgentInvocationId) { - return this.subAgentInvocationId === other.subAgentInvocationId; - } - - // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped - if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || ChatSubagentContentPart.isParentSubagentTool(other))) { - // For parent subagent tool, use toolCallId as the effective ID - const otherEffectiveId = other.subAgentInvocationId ?? other.toolCallId; - // If both have IDs, they must match - if (this.subAgentInvocationId && otherEffectiveId) { - return this.subAgentInvocationId === otherEffectiveId; - } - // Fallback for tools without IDs - group if this part has no ID and tool has no ID - return !this.subAgentInvocationId && !otherEffectiveId; - } - return false; + return (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') + && ChatSubagentContentPart.isParentSubagentTool(other) + && this.subAgentInvocationId === other.toolCallId; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 0ba3e281517..9c240b13257 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1241,7 +1241,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0 || this.getSubagentPart(templateData.renderedParts)) { + if (this.getPendingToolConfirmationCount(partsToRender, true) > 0) { return undefined; } @@ -1252,8 +1252,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(part))) { + if (workingParts.some(part => part.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(part))) { return undefined; } // Don't show working spinner when there's an in-progress MCP tool - MCP tools have their own progress indicator - if (partsToRender.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part) && isMcpToolInvocation(part))) { + if (workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part) && isMcpToolInvocation(part))) { return undefined; } @@ -1301,23 +1301,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part instanceof ChatThinkingContentPart); - const hasEditPillMarkdown = partsToRender.some(part => part.kind === 'markdownContent' && this.hasEditCodeblockUri(part)); + const hasEditPillMarkdown = workingParts.some(part => part.kind === 'markdownContent' && this.hasEditCodeblockUri(part)); if (hasRenderedThinkingPart && hasEditPillMarkdown) { return undefined; } - // Don't show working spinner when there's any active subagent - subagents have their own progress indicator - if (this.getSubagentPart(templateData.renderedParts)) { - return undefined; - } - if ( !lastPart || lastPart.kind === 'references' || (lastPart.kind === 'markdownContent' && !moreContentAvailable && this.hasBeenCaughtUpLongEnough(element)) || ((lastPart.kind === 'toolInvocation' || lastPart.kind === 'toolInvocationSerialized') && (IChatToolInvocation.isComplete(lastPart) || IChatToolInvocation.isEffectivelyHidden(lastPart))) || - ((lastPart.kind === 'textEditGroup' || lastPart.kind === 'notebookEditGroup') && lastPart.done && !partsToRender.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || - (lastPart.kind === 'externalEdit' && !partsToRender.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || + ((lastPart.kind === 'textEditGroup' || lastPart.kind === 'notebookEditGroup') && lastPart.done && !workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || + (lastPart.kind === 'externalEdit' && !workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || lastPart.kind === 'mcpServersStarting' || lastPart.kind === 'disabledClaudeHooks' || @@ -1457,16 +1452,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer= 0; i--) { - const part = partsToRender[i]; - if (part.kind !== 'markdownContent' || part.content.value.trim().length > 0) { - return part; - } - } - return undefined; - } - /** * True while we have caught up to streamed markdown but are still within the * {@link WORKING_CAUGHT_UP_DEBOUNCE_MS} window before the working indicator @@ -1484,7 +1469,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { // Accumulate counts from the part that ended up at the previous index if (contentIndex > 0) { @@ -1828,7 +1814,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'working'); if (alreadyRenderedPart) { if (partToRender.kind === 'thinking' && alreadyRenderedPart instanceof ChatThinkingContentPart) { if (!Array.isArray(partToRender.value)) { @@ -1854,7 +1849,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer + (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') + && other.toolCallId === toolInvocation.toolCallId); } return lastSubagent; } @@ -3421,7 +3431,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer + other.kind === 'markdownContent' + && other.content.value === markdown.content.value + && extractSubAgentInvocationIdFromText(other.content.value) === subAgentInvocationId); } } @@ -3624,3 +3637,25 @@ function getSubagentId(invocation: IChatToolInvocation | IChatToolInvocationSeri function isSubagentToolInvocation(invocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { return !!getSubagentId(invocation); } + +export function getWorkingProgressRelevantParts(parts: readonly IChatRendererContent[]): IChatRendererContent[] { + return parts.filter(part => { + if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { + return !isSubagentToolInvocation(part); + } + if (part.kind === 'hook') { + return !part.subAgentInvocationId; + } + return part.kind !== 'markdownContent' || !extractSubAgentInvocationIdFromText(part.content.value); + }); +} + +function findLastMeaningfulPart(parts: readonly IChatRendererContent[]): IChatRendererContent | undefined { + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i]; + if (part.kind !== 'markdownContent' || part.content.value.trim().length > 0) { + return part; + } + } + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index f1b62786e02..b97c58908de 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1031,6 +1031,7 @@ export interface IChatPullRequestContent { export interface IChatSubagentToolInvocationData { kind: 'subagent'; + isActive?: boolean; description?: string; agentName?: string; prompt?: string; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index c0ee99ed879..34da67e4aa9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -23,7 +23,7 @@ import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey import { ActionType, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { CustomizationType, type ClientPluginCustomization, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer, chatReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -69,7 +69,7 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { type ContextKeyValue } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IAgentHostCustomizationService, NullAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; -import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { IChatWidgetService } from '../../../browser/chat.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; @@ -497,7 +497,7 @@ class MockChatWidgetService extends mock() { // ---- Helpers ---------------------------------------------------------------- -function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial, languageModels?: ReadonlyMap, provisionalServiceOverride?: Partial, isSessionsWindow = false) { +function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial, languageModels?: ReadonlyMap, provisionalServiceOverride?: Partial, isSessionsWindow = false, languageModelToolsServiceOverride?: Partial) { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); @@ -561,6 +561,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv onDidChangeTools: Event.None, getTools: () => [], _serviceBrand: undefined, + ...languageModelToolsServiceOverride, }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None }); @@ -698,8 +699,8 @@ function createSessionListController(disposables: DisposableStore, instantiation return disposables.add(instantiationService.createInstance(AgentHostSessionListController, sessionType, provider, sessionListStore, description, 'local')); } -function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial }) { - const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride); +function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial; languageModelToolsServiceOverride?: Partial }) { + const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride, false, opts?.languageModelToolsServiceOverride); const listController = createSessionListController(disposables, instantiationService, agentHostService); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -2689,6 +2690,10 @@ suite('AgentHostChatContribution', () => { toolCallId: parentToolCallId, content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], } as ChatAction); + fire({ + type: 'chat/toolCallComplete', session, turnId, toolCallId: parentToolCallId, + result: { success: true, pastTenseMessage: 'Spawned subagent' }, + } as ChatAction); await timeout(50); @@ -6575,7 +6580,7 @@ suite('AgentHostChatContribution', () => { /** * Build a child session state containing a single inner tool call in the running state. */ - function makeChildState(childUri: string, innerToolCallId: string): SeededSessionState { + function makeChildState(childUri: string, innerToolCallId: string, contributor?: ToolCallState['contributor']): SeededSessionState { const summary: SessionSummary = { resource: childUri, provider: 'copilot', @@ -6592,6 +6597,7 @@ suite('AgentHostChatContribution', () => { invocationMessage: 'Reading file', toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, + contributor, } as ToolCallState; const activeTurn = createActiveTurn('child-turn-1', { text: 'do work', origin: { kind: MessageKind.User } }); activeTurn.responseParts.push({ kind: ResponsePartKind.ToolCall, toolCall: innerTool }); @@ -6636,9 +6642,6 @@ suite('AgentHostChatContribution', () => { // Allow the throttler/observation flow to flush. await timeout(50); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); - await turnPromise; - // Flatten all progress emissions and find tool invocations. const allParts = collected.flat(); const toolInvocations = allParts.filter((p): p is IChatToolInvocation => p.kind === 'toolInvocation'); @@ -6649,9 +6652,22 @@ suite('AgentHostChatContribution', () => { assert.ok(parent, 'parent task tool invocation should be emitted'); assert.strictEqual(parent!.toolSpecificData?.kind, 'subagent', 'parent should have subagent toolSpecificData'); assert.strictEqual(parent!.subAgentInvocationId, undefined, 'parent should not have a subAgentInvocationId'); + assert.strictEqual((parent!.toolSpecificData as IChatSubagentToolInvocationData).isActive, true); assert.ok(child, 'inner child tool invocation should be forwarded into parent session progress'); assert.strictEqual(child!.subAgentInvocationId, parentToolCallId, 'child should be tagged with parent tool call id for grouping'); + + agentHostService.fireAction({ + channel: childSessionUri, + action: { type: 'chat/turnComplete', turnId: 'child-turn-1' } as ChatAction, + serverSeq: 1001, + origin: undefined, + }); + await timeout(50); + assert.strictEqual((parent!.toolSpecificData as IChatSubagentToolInvocationData).isActive, false); + + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; })); test('inner subagent tool calls fired AFTER parent observation are also grouped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -6726,6 +6742,50 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(child!.subAgentInvocationId, parentToolCallId, 'child should be tagged for grouping'); })); + test('client-provided subagent tool calls retain the parent grouping id', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + let capturedSubagentInvocationId: string | undefined; + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + languageModelToolsServiceOverride: { + getToolByName: () => ({ id: 'vscode_readSkill', source: ToolDataSource.Internal, displayName: 'Read skill', modelDescription: 'Reads a skill' }), + beginToolCall: options => { + capturedSubagentInvocationId = options.subagentInvocationId; + return undefined; + }, + }, + }); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + const parentToolCallId = 'tc-parent-task'; + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + const childSessionUri = buildSubagentChatUri(parentSession, parentToolCallId); + agentHostService.sessionStates.set(childSessionUri, makeChildState(childSessionUri, 'tc-child-skill', { + kind: ToolCallContributorKind.Client, + clientId: agentHostService.clientId, + })); + + fire({ + type: 'chat/toolCallStart', session, turnId, + toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', + _meta: { toolKind: 'subagent', subagentDescription: 'review code' }, + } as ChatAction); + fire({ + type: 'chat/toolCallReady', session, turnId, + toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', + confirmed: 'not-needed', + } as ChatAction); + fire({ + type: 'chat/toolCallContentChanged', session, turnId, + toolCallId: parentToolCallId, + content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], + } as ChatAction); + + await timeout(50); + assert.strictEqual(capturedSubagentInvocationId, parentToolCallId); + + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + })); + test('parent subagent agentName is updated when subagent content arrives later', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // Repro for the missing-agent-name bug: when the parent task tool // fires without `subagentAgentName` in `_meta` (e.g. the agent host diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 988709e2488..13e042374c4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -1121,6 +1121,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); if (invocation.toolSpecificData?.kind === 'subagent') { invocation.toolSpecificData.credits = 2.5; + invocation.toolSpecificData.isActive = true; } finalizeToolInvocation(invocation, { @@ -1146,7 +1147,13 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); if (invocation.toolSpecificData?.kind === 'subagent') { - assert.strictEqual(invocation.toolSpecificData.credits, 2.5, 'credits should survive finalization'); + assert.deepStrictEqual({ + credits: invocation.toolSpecificData.credits, + isActive: invocation.toolSpecificData.isActive, + }, { + credits: 2.5, + isActive: true, + }); } }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 832edf81b6d..b1e3678d06b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -301,6 +301,56 @@ suite('ChatSubagentContentPart', () => { assert.ok(part.domNode.classList.contains('chat-thinking-fixed-mode'), 'Should have chat-thinking-fixed-mode class'); }); + test('should shimmer for an in-progress subagent even when the response is complete', () => { + const toolInvocation = createMockToolInvocation({ stateType: IChatToolInvocation.StateKind.Executing }); + const context = createMockRenderContext(true); + + const part = createPart(toolInvocation, context); + + assert.ok(part.domNode.querySelector('.chat-thinking-title-shimmer')); + }); + + test('should not shimmer for a completed subagent while the response is in progress', () => { + const toolInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Completed task', + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + assert.deepStrictEqual({ + isActive: part.getIsActive(), + hasShimmer: !!part.domNode.querySelector('.chat-thinking-title-shimmer'), + }, { + isActive: false, + hasShimmer: false, + }); + }); + + test('should shimmer while Agent Host reports an active child chat after tool completion', () => { + const toolInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + isActive: true, + description: 'Running child chat', + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + assert.deepStrictEqual({ + isActive: part.getIsActive(), + hasShimmer: !!part.domNode.querySelector('.chat-thinking-title-shimmer'), + }, { + isActive: true, + hasShimmer: true, + }); + }); + test('should start collapsed', () => { const toolInvocation = createMockToolInvocation(); const context = createMockRenderContext(false); @@ -610,7 +660,7 @@ suite('ChatSubagentContentPart', () => { }); suite('hasSameContent', () => { - test('should return true for tool invocation with same subAgentInvocationId', () => { + test('should not reuse the visual part for a child tool invocation', () => { const toolInvocation = createMockToolInvocation({ subAgentInvocationId: 'subagent-123' }); const context = createMockRenderContext(false); @@ -622,7 +672,7 @@ suite('ChatSubagentContentPart', () => { }); const result = part.hasSameContent(otherInvocation, [], context.element); - assert.strictEqual(result, true, 'Should match tool invocation with same subAgentInvocationId'); + assert.strictEqual(result, false); }); test('should return false for tool invocation with different subAgentInvocationId', () => { @@ -659,19 +709,19 @@ suite('ChatSubagentContentPart', () => { assert.strictEqual(result, true, 'Should match runSubagent tool using toolCallId as effective ID'); }); - test('should return true for markdownContent (allowing grouping)', () => { - const toolInvocation = createMockToolInvocation(); + test('should not reuse the visual part for grouped markdown', () => { + const toolInvocation = createMockToolInvocation({ toolCallId: 'subagent-123' }); const context = createMockRenderContext(false); const part = createPart(toolInvocation, context); const markdownContent: IChatMarkdownContent = { kind: 'markdownContent', - content: { value: 'test' } + content: { value: 'file:///test.txt' } }; const result = part.hasSameContent(markdownContent, [], context.element); - assert.strictEqual(result, true, 'Should match markdownContent to allow grouping'); + assert.strictEqual(result, false); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 9f62aae2d49..53f81dc742c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -6,7 +6,10 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { buildPlanReviewProgressContent, shouldHideChatUserIdentity, shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, getWorkingProgressRelevantParts, shouldHideChatUserIdentity, shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; +import { IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; suite('ChatListRenderer', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -71,4 +74,37 @@ suite('ChatListRenderer', () => { assert.strictEqual(content.value, 'Approved plan\n\n## Plan summary\n\n[Open full plan file (plan.md)](file:///sessions/abc/plan.md?vscodeLinkType=file)'); }); }); + + test('working progress ignores subagent-owned response parts', () => { + const parentSubagent: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'subagent-1', + toolId: 'task', + source: ToolDataSource.Internal, + invocationMessage: 'Running subagent', + originMessage: undefined, + pastTenseMessage: undefined, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + isComplete: true, + presentation: undefined, + toolSpecificData: { kind: 'subagent', description: 'Investigate' }, + }; + const childTool: IChatToolInvocationSerialized = { + ...parentSubagent, + toolCallId: 'child-1', + toolId: 'search', + subAgentInvocationId: 'subagent-1', + toolSpecificData: undefined, + }; + const parts: IChatRendererContent[] = [ + { kind: 'references', references: [] }, + parentSubagent, + childTool, + { kind: 'markdownContent', content: { value: 'file:///test.txt' } }, + { kind: 'hook', hookType: 'PreToolUse', subAgentInvocationId: 'subagent-1' }, + ]; + + assert.deepStrictEqual(getWorkingProgressRelevantParts(parts).map(part => part.kind), ['references']); + }); + }); From 3459fc2b14f75ea1f4f156335cdccb582fbd37a5 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 30 Jun 2026 18:43:18 +1000 Subject: [PATCH 22/59] refactor: streamline agent, rule, and skill discovery with Promise.all for improved performance (#323677) * refactor: streamline agent, rule, and skill discovery with Promise.all for improved performance * refactor: remove unused import for raceCancellation in sessionCustomizationDiscovery --- .../copilot/sessionCustomizationDiscovery.ts | 95 +++++++++++-------- 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts index dbe57c2b591..cdceb65a8b7 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts @@ -19,6 +19,7 @@ import type { AgentsDiscoverRequest } from './copilotRCP.js'; import { AgentCustomization, ChildCustomization, CustomizationLoadStatus, CustomizationType, DirectoryCustomization, RuleCustomization, SkillCustomization, customizationId } from '../../common/state/sessionState.js'; import { ChildCustomizationType } from '../../common/state/protocol/state.js'; import { toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; +import { raceCancellationError } from '../../../../base/common/async.js'; /** * The kinds of customizations the agent host discovers from disk. @@ -248,38 +249,12 @@ export class SessionCustomizationDiscovery extends Disposable { const p: AgentsDiscoverRequest = { projectPaths: [this._workingDirectory.fsPath] }; try { - const agents: AgentCustomization[] = []; - - const agentDiscovery = await client.rpc.agents.discover(p); - for (const agent of agentDiscovery.agents) { - if (agent.path) { - const uri = URI.file(agent.path); - agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); - } - } - - const rules: RuleCustomization[] = []; - - const instructionDiscovery = await client.rpc.instructions.discover(p); - for (const instruction of instructionDiscovery.sources) { - let uri: URI; - if (isAbsolute(instruction.sourcePath)) { - uri = URI.file(instruction.sourcePath); - } else { - uri = joinPath(this._workingDirectory, instruction.sourcePath); - } - rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); - } - - const skills: SkillCustomization[] = []; - - const skillDiscovery = await client.rpc.skills.discover(p); - for (const skill of skillDiscovery.skills) { - if (skill.path) { - const uri = URI.file(skill.path); - skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); - } - } + const [agents, rules, skills] = await Promise.all([ + this.discoverAgents(p, client, token), + this.discoverRules(p, client, token), + this.discoverSkills(p, client, token) + ]); + throwIfCancelled(token); const result: DirectoryCustomization[] = []; this.toDirectoryCustomizations(CustomizationType.Agent, agents, result); @@ -292,6 +267,48 @@ export class SessionCustomizationDiscovery extends Disposable { } } + private async discoverAgents(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const agents: AgentCustomization[] = []; + + const agentDiscovery = await raceCancellationError(client.rpc.agents.discover(discoveryRequest), token); + for (const agent of agentDiscovery.agents) { + if (agent.path) { + const uri = URI.file(agent.path); + agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); + } + } + return agents; + } + + private async discoverRules(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const rules: RuleCustomization[] = []; + + const instructionDiscovery = await raceCancellationError(client.rpc.instructions.discover(discoveryRequest), token); + for (const instruction of instructionDiscovery.sources) { + let uri: URI; + if (isAbsolute(instruction.sourcePath)) { + uri = URI.file(instruction.sourcePath); + } else { + uri = joinPath(this._workingDirectory, instruction.sourcePath); + } + rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); + } + return rules; + } + + private async discoverSkills(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise { + const skills: SkillCustomization[] = []; + + const skillDiscovery = await raceCancellationError(client.rpc.skills.discover(discoveryRequest), token); + for (const skill of skillDiscovery.skills) { + if (skill.path) { + const uri = URI.file(skill.path); + skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); + } + } + return skills; + } + private toDirectoryCustomizations(type: ChildCustomizationType, customizations: readonly ChildCustomization[], result: DirectoryCustomization[]): void { const byParent = new ResourceMap<{ readonly uri: URI; readonly children: ChildCustomization[] }>(); for (const customization of customizations) { @@ -426,11 +443,11 @@ export class SessionCustomizationDiscovery extends Disposable { */ private async _scanFixedDiscoveryFiles(base: URI, roots: IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap, token: CancellationToken): Promise { const filesByType = new Map(); - for (const root of roots) { + await Promise.all(roots.map(async root => { throwIfCancelled(token); if (!await this._watchAncestors(base, root.path, watchRootUris, token)) { - continue; + return; } const rootUri = joinPath(base, ...root.path); @@ -439,10 +456,10 @@ export class SessionCustomizationDiscovery extends Disposable { stat = await this._fileService.resolve(rootUri, { resolveMetadata: true }); } catch { // Root does not exist (or is unreadable) — nothing to discover or watch. - continue; + return; } if (!stat.isDirectory || !stat.children) { - continue; + return; } // Trigger refresh only for the specific filenames this root cares about @@ -463,7 +480,7 @@ export class SessionCustomizationDiscovery extends Disposable { } } } - } + })); for (const [type, files] of filesByType.entries()) { if (files.length > 0) { @@ -492,7 +509,7 @@ export class SessionCustomizationDiscovery extends Disposable { if (root.type === DiscoveredType.Skill) { const files: IDiscoveredFile[] = []; - for (const child of children) { + await Promise.all(children.map(async child => { throwIfCancelled(token); if (child.isDirectory) { @@ -507,7 +524,7 @@ export class SessionCustomizationDiscovery extends Disposable { // SKILL.md missing — skip this skill directory. } } - } + })); result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); } else if (root.type === DiscoveredType.Agent) { const files: IDiscoveredFile[] = []; From 2ad1a5209e0699027b623a9bb29097cfc3f60683 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" Date: Tue, 30 Jun 2026 08:53:51 +0000 Subject: [PATCH 23/59] [cherry-pick] Skip for now the sanity checks --- .../src/extension/test/vscode-node/sanity.sanity-test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts b/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts index ce33ef5980a..f740f6f4ce1 100644 --- a/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts +++ b/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts @@ -89,7 +89,7 @@ suite('Copilot Chat Sanity Test', function () { }); }); - test('E2E Production Panel Chat Test', async function () { + test.skip('E2E Production Panel Chat Test', async function () { assert.ok(realInstaAccessor, 'Instantiation service accessor is not available'); await realInstaAccessor.invokeFunction(async (accessor) => { @@ -120,7 +120,7 @@ suite('Copilot Chat Sanity Test', function () { * Runs tools outside of a real chat session which is unusual but lets us spy more * Uses an empty window with no folder open */ - test('E2E Production agent mode', async function () { + test.skip('E2E Production agent mode', async function () { assert.ok(realInstaAccessor, 'Instantiation service accessor is not available'); await realInstaAccessor.invokeFunction(async (accessor) => { @@ -187,7 +187,7 @@ suite('Copilot Chat Sanity Test', function () { }); }); - test('Slash Commands work properly', async function () { + test.skip('Slash Commands work properly', async function () { assert.ok(realInstaAccessor); await realInstaAccessor.invokeFunction(async (accessor) => { From ff62b245a3e0fda516f8eb60c84d9b391134844a Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:58:49 +0200 Subject: [PATCH 24/59] Sessions - hide entire CI input banner once a fix is requested (#323683) Previously only the Fix Checks button was hidden after requesting a CI fix for the current PR head commit; the banner stayed visible with the Reveal Checks action. Now the whole CI input banner is hidden until a new commit lands on the PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionInputBanners.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts index 8fbc94e00e6..19fb1d9f0ce 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -40,8 +40,6 @@ interface ICIBannerState { readonly completed: number; /** Number of checks still running or queued. */ readonly pending: number; - /** Whether the user already requested a CI fix for the current PR head commit. */ - readonly fixRequested: boolean; } interface ICommentsBannerState { @@ -104,6 +102,11 @@ export class SessionInputBanners extends Disposable { if (!ciModel) { return undefined; } + // Once the user has requested a CI fix for the current PR head commit, + // hide the entire banner until a new commit lands on the PR. + if (ciModel.fixRequested.read(reader)) { + return undefined; + } const checks = ciModel.checks.read(reader); const failed = getFailedChecks(checks).length; if (failed === 0) { @@ -111,7 +114,7 @@ export class SessionInputBanners extends Disposable { } const completed = checks.filter(check => check.status === GitHubCheckStatus.Completed).length; const pending = checks.length - completed; - return { sessionId: session.sessionId, failed, completed, pending, fixRequested: ciModel.fixRequested.read(reader) }; + return { sessionId: session.sessionId, failed, completed, pending }; }); private readonly _commentsState: IObservable = derived(this, reader => { @@ -188,11 +191,11 @@ export class SessionInputBanners extends Disposable { ariaLabel: text, dismissTooltip: localize('ci.dismiss', "Hide for this session"), actions: [ - ...(state.fixRequested ? [] : [{ + { label: localize('ci.fixChecks', "Fix Checks"), primary: true, run: () => this._executeCommand(FIX_CI_CHECKS_COMMAND_ID), - }]), + }, { label: localize('ci.revealChecks', "Reveal Checks"), run: () => this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID), From 83eae7c06e25cab9892121791327e5ab1d4d4615 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Tue, 30 Jun 2026 11:05:25 +0200 Subject: [PATCH 25/59] Attestation commit From 81d2bdf0353495ffa814fb19399c40848ee14024 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:09:04 +0200 Subject: [PATCH 26/59] Focus feedback input after clicking glyph margin add button (#323686) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentFeedbackEditorInputContribution.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 22e67942c21..0ed2a08d2b3 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -571,6 +571,12 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements // selection and opens the input for the freshly selected line. this._editor.setSelection(new Selection(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber))); this._editor.focus(); + + // Focusing the editor synchronously opens the input via the + // selection-change handler, so move focus into it now that it is + // visible. This lets the user type feedback immediately after clicking + // the gutter glyph without having to click the input first. + this.focusInput(); } private _getSessionForModel(): ISession | undefined { From 48e992cc1c6ad778d6adb73984768b4712dc6b29 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 30 Jun 2026 10:15:31 +0100 Subject: [PATCH 27/59] fix: adjust margins for floating panels and editor positioning to improve layout consistency Co-authored-by: Copilot --- src/vs/workbench/browser/layout.ts | 5 ++++- src/vs/workbench/browser/media/floatingPanels.css | 15 +++++++++++++++ .../workbench/browser/parts/editor/editorPart.ts | 5 ++++- .../workbench/browser/parts/paneCompositePart.ts | 7 +++++-- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index f7701c3972b..c799d271a3d 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -1902,7 +1902,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi !this.isVisible(Parts.STATUSBAR_PART) ? LayoutClasses.STATUSBAR_HIDDEN : undefined, this.state.runtime.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.isShadowsDisabled() ? LayoutClasses.NO_SHADOWS : undefined, - this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined + this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined, + `panel-position-${positionToString(this.getPanelPosition())}` ]); } @@ -2366,6 +2367,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi const panelContainer = assertReturnsDefined(panelPart.getContainer()); panelContainer.classList.remove(oldPositionValue); panelContainer.classList.add(newPositionValue); + this.mainContainer.classList.remove(`panel-position-${oldPositionValue}`); + this.mainContainer.classList.add(`panel-position-${newPositionValue}`); // Update Styles panelPart.updateStyles(); diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 7f28295d02d..ff798f8a0cb 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -48,6 +48,14 @@ margin-top: 0; } +/* Panel at top position: also flush with the title bar (no top margin), like the side bars. + * The panel element itself carries the position class (e.g. `.top`). */ +.monaco-workbench.floating-panels .part.panel.top, +.monaco-workbench.floating-panels .part.panel.left, +.monaco-workbench.floating-panels .part.panel.right { + margin-top: 0; +} + /* * When a floating pane composite (primary side bar, secondary side bar or a vertical * panel) is the outermost card on a window edge, it adopts a doubled outer gutter so @@ -68,6 +76,13 @@ margin-top: 0; } +/* When the panel is at the top and visible the editor sits below it — give it a top margin + * so the inter-card gap matches the gaps between the other floating cards. + * Requires `.panel-position-top` on `.monaco-workbench` (set in layout.ts). */ +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel) > .monaco-grid-view .part.editor { + margin-top: var(--vscode-spacing-size40); +} + /* * When the editor becomes the outermost card on a side (no floating part sits * between it and the window edge) it adopts the same doubled gutter the side/aux diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 771cb6b3951..ab15ba742d1 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1392,7 +1392,10 @@ export class EditorPart extends Part implements IEditorPart, const rightMargin = outerRight ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; width = Math.max(0, width - leftMargin - rightMargin); - height = Math.max(0, height - FLOATING_PANEL_MARGIN); + // When the panel is positioned above the editor and visible, the editor is no longer + // adjacent to the title bar — reserve a top margin to match the inter-card gaps. + const panelAtTop = this.layoutService.isVisible(Parts.PANEL_PART) && this.layoutService.getPanelPosition() === Position.TOP; + height = Math.max(0, height - FLOATING_PANEL_MARGIN - (panelAtTop ? FLOATING_PANEL_MARGIN : 0)); // Reserve space for the Modern UI editor border (styleOverrides/media/editorBorder.css) so content doesn't get clipped. if (!this.element.classList.contains('modal-editor-part')) { diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 9a6763399ae..35e4a2eb9a9 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -12,7 +12,7 @@ import { IPaneComposite } from '../../common/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../common/views.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IView } from '../../../base/browser/ui/grid/grid.js'; -import { IWorkbenchLayoutService, Parts, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges } from '../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts, Position, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges } from '../../services/layout/browser/layoutService.js'; import { CompositePart, ICompositePartOptions, ICompositeTitleLabel } from './compositePart.js'; import { IPaneCompositeBarOptions, PaneCompositeBar } from './paneCompositeBar.js'; import { Dimension, EventHelper, trackFocus, $, addDisposableListener, EventType, prepend, getWindow } from '../../../base/browser/dom.js'; @@ -655,7 +655,10 @@ export abstract class AbstractPaneCompositePart extends CompositePart Date: Tue, 30 Jun 2026 11:24:23 +0200 Subject: [PATCH 28/59] Fix button wrapping in onboarding tour spotlight (#323678) * Agent Host changes for agents/fix-onboarding-tour-button-wrap * Fix onboarding tour button wrap and accessibility issues * Fix stray character causing compile error in chatAccessibilityProvider Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/onboarding/browser/media/spotlight.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css index cf385c11e43..b706b7f3414 100644 --- a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css +++ b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css @@ -130,9 +130,15 @@ .spotlight-callout-actions { display: flex; + flex-wrap: wrap; gap: var(--vscode-spacing-size80); } +.spotlight-callout-actions .monaco-button { + width: fit-content; + white-space: nowrap; +} + /* Respect reduced-motion: drop the animated hole transition. */ @media (prefers-reduced-motion: reduce) { .spotlight-hole { From b30acd1b1d9f454691c1d7c492ab048a4052ef49 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 30 Jun 2026 10:59:24 +0100 Subject: [PATCH 29/59] fix: enhance floating panel margins and alignment for improved layout consistency Co-authored-by: Copilot --- src/vs/workbench/browser/layout.ts | 7 ++- .../browser/media/floatingPanels.css | 50 +++++++++++++++++++ .../browser/parts/editor/editorPart.ts | 8 ++- .../browser/parts/paneCompositePart.ts | 27 +++++++++- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index c799d271a3d..d5da4b4de33 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -1903,7 +1903,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.state.runtime.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.isShadowsDisabled() ? LayoutClasses.NO_SHADOWS : undefined, this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined, - `panel-position-${positionToString(this.getPanelPosition())}` + `panel-position-${positionToString(this.getPanelPosition())}`, + `panel-alignment-${this.getPanelAlignment()}` ]); } @@ -2035,7 +2036,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // panel alignment requires the editor part to be visible this.setAuxiliaryBarMaximized(false); + // Adjust CSS — capture old value before updating state model + const oldAlignmentValue = this.getPanelAlignment(); this.stateModel.setRuntimeValue(LayoutStateKeys.PANEL_ALIGNMENT, alignment); + this.mainContainer.classList.remove(`panel-alignment-${oldAlignmentValue}`); + this.mainContainer.classList.add(`panel-alignment-${alignment}`); this.adjustPartPositions(this.getSideBarPosition(), alignment, this.getPanelPosition()); diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index ff798f8a0cb..53fad1687a3 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -144,3 +144,53 @@ .monaco-workbench.floating-panels .activitybar > .content > .composite-bar { margin-top: var(--vscode-spacing-size20); } + +/* + * When the status bar is hidden every floating card sits directly against the window + * bottom edge. Double the bottom margin (4px → 8px) to match the doubled outer gutter + * applied to other window-edge sides, so all gaps look consistent. + * Exceptions: + * - Panel at TOP: its bottom faces the editor (not the window edge) — keep 4px. + * - Editor when a bottom panel is visible: it abuts the panel card — keep 4px. + * The code-side insets in AbstractPaneCompositePart and EditorPart are kept in sync. + */ +.monaco-workbench.floating-panels.nostatusbar .part.panel.bottom, +.monaco-workbench.floating-panels.nostatusbar .part.panel.left, +.monaco-workbench.floating-panels.nostatusbar .part.panel.right, +.monaco-workbench.floating-panels.nostatusbar .part.sidebar, +.monaco-workbench.floating-panels.nostatusbar .part.auxiliarybar { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} + +.monaco-workbench.floating-panels.nostatusbar > .monaco-grid-view .part.editor { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} + +/* + * When a visible bottom panel directly abuts the sidebars and/or editor (they share + * the same grid row), keep the normal 4px inter-card gap instead of the doubled 8px. + * Which bars are in the same row as the editor depends on panel alignment: + * justify → both sidebars are in the top row (above the full-width panel) + * left → the bar on the LEFT is in the top row; the bar on the RIGHT is full-height + * right → the bar on the RIGHT is in the top row; the bar on the LEFT is full-height + * center → neither sidebar is in the top row (both span full height) + * Uses `.panel-alignment-*` and `.left`/`.right` position classes set in layout.ts. + */ +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-justify .part.sidebar, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-justify .part.auxiliarybar, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-left .part.sidebar.left, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-left .part.auxiliarybar.left, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-right .part.sidebar.right, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-right .part.auxiliarybar.right, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel) > .monaco-grid-view .part.editor { + margin-bottom: var(--vscode-spacing-size40); +} +/* Editor exception for center alignment: panel is within the editor column, so editor + * still faces it from above (top row) — override the above back to 4px. */ +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-center > .monaco-grid-view .part.editor { + margin-bottom: var(--vscode-spacing-size40); +} + +.monaco-workbench.floating-panels.nostatusbar .part.activitybar { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index ab15ba742d1..e287f123bdc 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1395,7 +1395,13 @@ export class EditorPart extends Part implements IEditorPart, // When the panel is positioned above the editor and visible, the editor is no longer // adjacent to the title bar — reserve a top margin to match the inter-card gaps. const panelAtTop = this.layoutService.isVisible(Parts.PANEL_PART) && this.layoutService.getPanelPosition() === Position.TOP; - height = Math.max(0, height - FLOATING_PANEL_MARGIN - (panelAtTop ? FLOATING_PANEL_MARGIN : 0)); + // When the status bar is hidden, the editor is at the window bottom edge — double the + // margin. Exception: when a bottom panel is visible the editor's bottom faces the panel + // card (not the window edge), so keep the normal inter-card gap. + const panelAtBottom = this.layoutService.isVisible(Parts.PANEL_PART) && this.layoutService.getPanelPosition() === Position.BOTTOM; + const bottomEditorMargin = !this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow) && !panelAtBottom + ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; + height = Math.max(0, height - (panelAtTop ? FLOATING_PANEL_MARGIN : 0) - bottomEditorMargin); // Reserve space for the Modern UI editor border (styleOverrides/media/editorBorder.css) so content doesn't get clipped. if (!this.element.classList.contains('modal-editor-part')) { diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 35e4a2eb9a9..77a77282e11 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -13,6 +13,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../common/view import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IView } from '../../../base/browser/ui/grid/grid.js'; import { IWorkbenchLayoutService, Parts, Position, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges } from '../../services/layout/browser/layoutService.js'; +import { mainWindow } from '../../../base/browser/window.js'; import { CompositePart, ICompositePartOptions, ICompositeTitleLabel } from './compositePart.js'; import { IPaneCompositeBarOptions, PaneCompositeBar } from './paneCompositeBar.js'; import { Dimension, EventHelper, trackFocus, $, addDisposableListener, EventType, prepend, getWindow } from '../../../base/browser/dom.js'; @@ -659,12 +660,36 @@ export abstract class AbstractPaneCompositePart extends CompositePart Date: Tue, 30 Jun 2026 11:10:30 +0100 Subject: [PATCH 30/59] fix: add top margin for sidebars when aligned with editor in top panel position Co-authored-by: Copilot --- .../browser/media/floatingPanels.css | 15 ++++++++++++++ .../browser/parts/paneCompositePart.ts | 20 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 53fad1687a3..778b0777ddb 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -48,6 +48,21 @@ margin-top: 0; } +/* + * When panel is at TOP and sidebars are in the same grid row as the editor (sibling), + * give them a top margin matching the editor's gap from the panel. Alignment determines + * which bars are sibling — mirrors adjustPartPositions() in layout.ts. + * Uses `.panel-alignment-*` and `.left`/`.right` classes set in layout.ts. + */ +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-justify .part.sidebar, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-justify .part.auxiliarybar, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-left .part.sidebar.left, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-left .part.auxiliarybar.left, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-right .part.sidebar.right, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-right .part.auxiliarybar.right { + margin-top: var(--vscode-spacing-size40); +} + /* Panel at top position: also flush with the title bar (no top margin), like the side bars. * The panel element itself carries the position class (e.g. `.top`). */ .monaco-workbench.floating-panels .part.panel.top, diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 77a77282e11..cf5d91d73c6 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -657,9 +657,25 @@ export abstract class AbstractPaneCompositePart extends CompositePart Date: Tue, 30 Jun 2026 12:41:15 +0200 Subject: [PATCH 31/59] Expand agent feedback widget when revealing feedback (#323687) * Expand agent feedback widget when revealing feedback revealFeedback set the navigation anchor to the raw feedback id, but the editor widget contribution matches against the prefixed session-editor- comment id. As a result the editor scrolled to the location but the widget stayed collapsed. Convert the feedback id to the session-editor-comment id so the matching widget expands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test wrapper, comment length and mock typing from review - Reintroduce the 'removing feedback preserves ordering' test() wrapper that was accidentally dropped, fixing the TS1128 compile error breaking CI. - Compress the revealFeedback inline comment to a single line. - Use unknown/undefined instead of any in the openEditor test stub. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Break import cycle flagged by cyclic dependency check sessionEditorComments.ts only uses AgentFeedbackKind/State and IAgentFeedback, which are defined in agentFeedbackModel.ts (the service merely re-exports them). Import them directly from the model so agentFeedbackService.ts can import sessionEditorComments.ts without forming a cycle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentFeedbackService.ts | 4 +++- .../browser/sessionEditorComments.ts | 2 +- .../test/browser/agentFeedbackService.test.ts | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 773968080fa..7028d35b068 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -27,6 +27,7 @@ import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider import { AnnotationsAgentFeedbackItemsBackend, IAgentFeedbackItemsBackend, InMemoryAgentFeedbackItemsBackend } from './agentFeedbackItemsBackend.js'; import { ATTACHMENT_ID_PREFIX, createAgentFeedbackVariableEntry } from './agentFeedbackAttachmentEntry.js'; import { AgentFeedbackKind, AgentFeedbackState, type IAgentFeedback } from './agentFeedbackModel.js'; +import { SessionEditorCommentSource, toSessionEditorCommentId } from './sessionEditorComments.js'; // --- Types -------------------------------------------------------------------- @@ -549,7 +550,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe if (!feedback) { return; } - await this.revealSessionComment(sessionResource, feedbackId, feedback.resourceUri, feedback.range); + // Anchor using the session-editor-comment id (not the raw feedback id) so the editor widget contribution matches the active item and expands its widget. + await this.revealSessionComment(sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedbackId), feedback.resourceUri, feedback.range); } async revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts index 5624f43a545..e4bce498f03 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -5,7 +5,7 @@ import { IRange, Range } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; -import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback } from './agentFeedbackService.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback } from './agentFeedbackModel.js'; import { ICodeReviewSuggestion, IPRReviewComment, IPRReviewState, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; export const enum SessionEditorCommentSource { diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 1b5353990de..dc806720b68 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -12,6 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { AgentFeedbackKind, AgentFeedbackService, AgentFeedbackState, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { getSessionEditorComments } from '../../browser/sessionEditorComments.js'; import { IChatEditingService } from '../../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentFeedbackVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -51,6 +52,7 @@ suite('AgentFeedbackService - Ordering', () => { instantiationService.stub(IEditorService, new class extends mock() { override onDidVisibleEditorsChange = Event.None; override visibleEditorPanes = []; + override openEditor(..._args: unknown[]): Promise { return Promise.resolve(undefined); } }); instantiationService.stub(ISessionsManagementService, new class extends mock() { override getSession(_resource: URI) { return undefined; } @@ -191,6 +193,25 @@ suite('AgentFeedbackService - Ordering', () => { assert.strictEqual(bearing.activeIdx, 2); }); + test('revealFeedback anchors the matching session editor comment so its widget expands', async () => { + const f1 = service.addFeedback(session, fileA, r(5), 'A:5'); + const f2 = service.addFeedback(session, fileA, r(20), 'A:20'); + + // The editor widget contribution expands the widget whose session + // editor comment matches the navigation anchor. revealFeedback must set + // the anchor using the prefixed session-editor-comment id (not the raw + // feedback id) for that match to succeed. + await service.revealFeedback(session, f2.id); + + const comments = getSessionEditorComments(session, service.getFeedback(session)); + const bearing = service.getNavigationBearing(session, comments); + assert.strictEqual(comments[bearing.activeIdx]?.sourceId, f2.id); + + await service.revealFeedback(session, f1.id); + const bearingAfter = service.getNavigationBearing(session, comments); + assert.strictEqual(comments[bearingAfter.activeIdx]?.sourceId, f1.id); + }); + test('removing feedback preserves ordering', () => { const f1 = service.addFeedback(session, fileA, r(30), 'A:30'); service.addFeedback(session, fileA, r(10), 'A:10'); From 534d1e04265b51e84da7053b0451748edb595201 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 30 Jun 2026 11:42:15 +0100 Subject: [PATCH 32/59] fix: simplify sidebar sibling check logic for improved readability and maintainability Co-authored-by: Copilot --- .../browser/media/floatingPanels.css | 5 ---- .../browser/parts/paneCompositePart.ts | 30 ++++++++++--------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 778b0777ddb..cb3ccd9e5f0 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -200,11 +200,6 @@ .monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel) > .monaco-grid-view .part.editor { margin-bottom: var(--vscode-spacing-size40); } -/* Editor exception for center alignment: panel is within the editor column, so editor - * still faces it from above (top row) — override the above back to 4px. */ -.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-center > .monaco-grid-view .part.editor { - margin-bottom: var(--vscode-spacing-size40); -} .monaco-workbench.floating-panels.nostatusbar .part.activitybar { margin-bottom: calc(var(--vscode-spacing-size40) * 2); diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index cf5d91d73c6..586731ded13 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -640,6 +640,20 @@ export abstract class AbstractPaneCompositePart extends CompositePart Date: Tue, 30 Jun 2026 11:53:08 +0100 Subject: [PATCH 33/59] fix: add getFloatingSidebarSiblingToEditorStatus function for improved layout handling Co-authored-by: Copilot --- .../browser/media/floatingPanels.css | 1 + .../browser/parts/editor/editorPart.ts | 5 +- .../browser/parts/paneCompositePart.ts | 12 ++--- .../services/layout/browser/layoutService.ts | 25 ++++++--- .../layout/test/browser/layoutService.test.ts | 51 ++++++++++++++++++- 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index cb3ccd9e5f0..1f8e04881fb 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -166,6 +166,7 @@ * applied to other window-edge sides, so all gaps look consistent. * Exceptions: * - Panel at TOP: its bottom faces the editor (not the window edge) — keep 4px. + * `.part.panel.top` is intentionally absent from the selector list below. * - Editor when a bottom panel is visible: it abuts the panel card — keep 4px. * The code-side insets in AbstractPaneCompositePart and EditorPart are kept in sync. */ diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index e287f123bdc..cae14f1864b 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1394,11 +1394,12 @@ export class EditorPart extends Part implements IEditorPart, width = Math.max(0, width - leftMargin - rightMargin); // When the panel is positioned above the editor and visible, the editor is no longer // adjacent to the title bar — reserve a top margin to match the inter-card gaps. - const panelAtTop = this.layoutService.isVisible(Parts.PANEL_PART) && this.layoutService.getPanelPosition() === Position.TOP; + const panelVisible = this.layoutService.isVisible(Parts.PANEL_PART); + const panelAtTop = panelVisible && this.layoutService.getPanelPosition() === Position.TOP; // When the status bar is hidden, the editor is at the window bottom edge — double the // margin. Exception: when a bottom panel is visible the editor's bottom faces the panel // card (not the window edge), so keep the normal inter-card gap. - const panelAtBottom = this.layoutService.isVisible(Parts.PANEL_PART) && this.layoutService.getPanelPosition() === Position.BOTTOM; + const panelAtBottom = panelVisible && this.layoutService.getPanelPosition() === Position.BOTTOM; const bottomEditorMargin = !this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow) && !panelAtBottom ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; height = Math.max(0, height - (panelAtTop ? FLOATING_PANEL_MARGIN : 0) - bottomEditorMargin); diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 586731ded13..499730f17e3 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -12,7 +12,7 @@ import { IPaneComposite } from '../../common/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../common/views.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IView } from '../../../base/browser/ui/grid/grid.js'; -import { IWorkbenchLayoutService, Parts, Position, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges } from '../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts, Position, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges, getFloatingSidebarSiblingToEditorStatus } from '../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../base/browser/window.js'; import { CompositePart, ICompositePartOptions, ICompositeTitleLabel } from './compositePart.js'; import { IPaneCompositeBarOptions, PaneCompositeBar } from './paneCompositeBar.js'; @@ -643,15 +643,11 @@ export abstract class AbstractPaneCompositePart extends CompositePart { @@ -80,3 +80,52 @@ suite('LayoutService - getFloatingOuterEdgeOwners', () => { }); }); }); + +suite('LayoutService - getFloatingSidebarSiblingToEditorStatus', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + class SiblingStatusLayoutService extends TestLayoutService { + sideBarPosition = Position.LEFT; + panelAlignment: PanelAlignment = 'center'; + + override getSideBarPosition(): Position { return this.sideBarPosition; } + override getPanelAlignment(): PanelAlignment { return this.panelAlignment; } + } + + function siblingStatus(configure: (s: SiblingStatusLayoutService) => void): { sideBar: boolean; auxBar: boolean } { + const s = new SiblingStatusLayoutService(); + configure(s); + return getFloatingSidebarSiblingToEditorStatus(s); + } + + test('sibling-to-editor status across alignment and sidebar-position combinations', () => { + const actual = { + // center: neither bar is a sibling (both span full height) + centerLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'center'; }), + centerRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'center'; }), + // justify: both bars are siblings (panel spans the full width) + justifyLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'justify'; }), + justifyRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'justify'; }), + // left alignment, sidebar on LEFT: sidebar IS sibling, aux bar is NOT + leftAlignSidebarLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'left'; }), + // left alignment, sidebar on RIGHT: sidebar is NOT sibling, aux bar IS + leftAlignSidebarRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'left'; }), + // right alignment, sidebar on LEFT: sidebar is NOT sibling, aux bar IS + rightAlignSidebarLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'right'; }), + // right alignment, sidebar on RIGHT: sidebar IS sibling, aux bar is NOT + rightAlignSidebarRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'right'; }), + }; + + assert.deepStrictEqual(actual, { + centerLeft: { sideBar: false, auxBar: false }, + centerRight: { sideBar: false, auxBar: false }, + justifyLeft: { sideBar: true, auxBar: true }, + justifyRight: { sideBar: true, auxBar: true }, + leftAlignSidebarLeft: { sideBar: true, auxBar: false }, + leftAlignSidebarRight: { sideBar: false, auxBar: true }, + rightAlignSidebarLeft: { sideBar: false, auxBar: true }, + rightAlignSidebarRight: { sideBar: true, auxBar: false }, + }); + }); +}); From 29555543d543cd30008d174e6f915e6624ec1bd5 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Tue, 30 Jun 2026 12:55:00 +0200 Subject: [PATCH 34/59] Add AH copilot SDK customization test (#323695) * add AH copilot sdk customization test * update * 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> --- .../copilotRealSdk.integrationTest.ts | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts index b7c494974a6..270ca1d13ed 100644 --- a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts @@ -23,12 +23,12 @@ */ import assert from 'assert'; -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; -import { MessageAttachmentKind, buildDefaultChatUri, ToolCallConfirmationReason, type MessageAttachment } from '../../../common/state/sessionState.js'; -import { ActionType, type ChatUsageAction } from '../../../common/state/sessionActions.js'; +import { CustomizationType, MessageAttachmentKind, buildDefaultChatUri, ToolCallConfirmationReason, type DirectoryCustomization, type MessageAttachment } from '../../../common/state/sessionState.js'; +import { ActionType, SessionCustomizationsChangedAction, type ChatUsageAction } from '../../../common/state/sessionActions.js'; import { createRealSession, defineSharedRealSdkTests, dispatchTurn, driveTurnWithAttachmentsToCompletion, type IRealSdkProviderConfig, @@ -164,6 +164,85 @@ defineSharedRealSdkTests(COPILOT_CONFIG); assert.match(result.responseText, /\bsubtract\b/i, `expected the model to identify the attached blob function; got: ${JSON.stringify(result.responseText)}`); }); + test('detects workspace agents, instructions, skills, and hooks via session/customizationsChanged after hello', async function () { + this.timeout(180_000); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-test-`); + tempDirs.push(workspaceDir); + const githubDir = join(workspaceDir, '.github'); + const agentsDir = join(githubDir, 'agents'); + const instructionsDir = join(githubDir, 'instructions'); + const skillsDir = join(githubDir, 'skills', 'hello-skill'); + const hooksDir = join(githubDir, 'hooks'); + + await Promise.all([ + mkdir(agentsDir, { recursive: true }), + mkdir(instructionsDir, { recursive: true }), + mkdir(skillsDir, { recursive: true }), + mkdir(hooksDir, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(agentsDir, 'hello.agent.md'), [ + '---', + 'name: Hello Agent', + 'description: Handles hello requests', + '---', + 'You are a test agent.', + ].join('\n')), + writeFile(join(instructionsDir, 'policy.instructions.md'), [ + '---', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')), + writeFile(join(skillsDir, 'SKILL.md'), [ + '---', + 'name: Hello Skill', + 'description: Says hello', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(join(hooksDir, 'pre-tool.json'), JSON.stringify({ PreToolUse: [] }, undefined, 2)), + ]); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations', createdSessions, URI.file(workspaceDir).toString()); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-client', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations', 'hello', 2); + + const [customizationsNotif] = await Promise.all([ + client.waitForNotification(n => isActionNotification(n, ActionType.SessionCustomizationsChanged) && getActionEnvelope(n).channel === sessionUri, 120_000), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete') && getActionEnvelope(n).channel === buildDefaultChatUri(sessionUri), 120_000), + ]); + + const customizationsAction = getActionEnvelope(customizationsNotif).action as SessionCustomizationsChangedAction; + const directories = customizationsAction.customizations.filter((customization): customization is DirectoryCustomization => customization.type === CustomizationType.Directory); + const expectChildType = (directoryUri: string, expectedType: CustomizationType, expectedName: string): void => { + const directory = directories.find(customization => customization.uri === directoryUri); + assert.ok(directory, `expected discovered directory ${directoryUri}`); + const matchingChildren = directory.children?.filter(child => child.type === expectedType && child.name === expectedName) ?? []; + assert.ok( + matchingChildren.length === 1, + `expected ${directoryUri} to contain a ${expectedType} customization with name ${expectedName}; got: ${JSON.stringify(directory.children)}`, + ); + }; + expectChildType(URI.file(agentsDir).toString(), CustomizationType.Agent, 'Hello Agent'); + expectChildType(URI.file(instructionsDir).toString(), CustomizationType.Rule, 'policy'); + expectChildType(URI.file(join(githubDir, 'skills')).toString(), CustomizationType.Skill, 'Hello Skill'); + expectChildType(URI.file(hooksDir).toString(), CustomizationType.Hook, 'pre-tool.json'); + }); + test('strips redundant `cd &&` prefix from shell tool calls', async function () { this.timeout(180_000); From 763728f68c0e2f47b9455debf003774632147cc7 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 30 Jun 2026 11:57:01 +0100 Subject: [PATCH 35/59] fix: optimize panel visibility checks for improved layout handling Co-authored-by: Copilot --- src/vs/workbench/browser/parts/paneCompositePart.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 499730f17e3..ea2c4cd5561 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -666,6 +666,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart Date: Tue, 30 Jun 2026 12:09:04 +0100 Subject: [PATCH 36/59] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/media/floatingPanels.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 1f8e04881fb..d92b36a5869 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -63,7 +63,7 @@ margin-top: var(--vscode-spacing-size40); } -/* Panel at top position: also flush with the title bar (no top margin), like the side bars. +/* Panels in top/left/right positions are flush with the title bar (no top margin). * The panel element itself carries the position class (e.g. `.top`). */ .monaco-workbench.floating-panels .part.panel.top, .monaco-workbench.floating-panels .part.panel.left, From 9a8648f0307a8845525c4efadf91c2cb5b11b2a9 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 30 Jun 2026 12:15:37 +0100 Subject: [PATCH 37/59] fix: update window reference for bottom margin calculation in pane composite part --- src/vs/workbench/browser/parts/paneCompositePart.ts | 3 +-- .../services/layout/test/browser/layoutService.test.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index ea2c4cd5561..6bdeae1c8d0 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -13,7 +13,6 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../common/view import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IView } from '../../../base/browser/ui/grid/grid.js'; import { IWorkbenchLayoutService, Parts, Position, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges, getFloatingSidebarSiblingToEditorStatus } from '../../services/layout/browser/layoutService.js'; -import { mainWindow } from '../../../base/browser/window.js'; import { CompositePart, ICompositePartOptions, ICompositeTitleLabel } from './compositePart.js'; import { IPaneCompositeBarOptions, PaneCompositeBar } from './paneCompositeBar.js'; import { Dimension, EventHelper, trackFocus, $, addDisposableListener, EventType, prepend, getWindow } from '../../../base/browser/dom.js'; @@ -697,7 +696,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart { From d1e6a9232ee818ae8a26c2f68df56b4c8e3887b9 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:33:53 +0200 Subject: [PATCH 38/59] sessions: reveal new chat session group when created (#323707) When creating a new group in the sessions list, scroll its header into view so the focused inline name editor is visible. Fixes #323528 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/views/sessionsList.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index dd60bdbdf3d..caac11a41a6 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -2233,6 +2233,21 @@ export class SessionsList extends Disposable implements ISessionsList { const group = this._sessionGroupsService.createGroup(localize('newGroupName', "New Group"), groupSessions.map(s => s.sessionId)); this._editingGroupId = group.id; this.update(); + this.revealGroup(group.id); + } + + /** Scroll the group's header into view so its inline name editor is visible. */ + private revealGroup(groupId: string): void { + const root = this.tree.getNode(); + for (const node of root.children) { + const element = node.element; + if (element && isSessionGroupItem(element) && element.group.id === groupId) { + if (this.tree.hasElement(element) && this.tree.getRelativeTop(element) === null) { + this.tree.reveal(element, 0.5); + } + return; + } + } } /** Begin inline renaming of the group's header. */ From f8bb6664a40c986c66b7dd4a16cb38bdab4a5ff7 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:37:02 +0200 Subject: [PATCH 39/59] Close diff/multi-diff editor on agent feedback submit (#323708) Close active editor in overlay group on feedback submit Always close the overlay group's active editor when feedback is submitted, so diff and multi-diff session editors close too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 460ea425081..4596ec52f1b 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -35,7 +35,7 @@ class SubmitFeedbackActionRunner extends ActionRunner { protected override async runAction(action: IAction, context?: unknown): Promise { const editorToClose = action.id === submitFeedbackActionId ? this._editorGroup.activeEditor : undefined; const didSubmit = await action.run(context); - if (didSubmit === true && editorToClose && this._editorGroup.contains(editorToClose)) { + if (didSubmit === true && editorToClose) { await this._editorGroup.closeEditor(editorToClose); } } From b168fa7e2e5cb1b8076ce861dcfc7bd05c5594c2 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:37:49 +0200 Subject: [PATCH 40/59] sessions: keep group/workspace order across reloads (#323705) The sessions list pruned manual section order/promotion entries on every render via retain(). Workspace identities are derived from asynchronously-loaded sessions, so early renders after a reload pruned workspace order entries before their sessions loaded, losing the user's interleaving of groups and workspaces (groups load synchronously and survived). Garbage-collect only on genuine removals (session removals and group changes), which happen after sessions are loaded, instead of on every render. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/views/sessionsList.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index caac11a41a6..466223996b8 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -1686,10 +1686,20 @@ export class SessionsList extends Disposable implements ISessionsList { updateFindPatternState(); })); - this._register(this._sessionsManagementService.onDidChangeSessions(() => { + this._register(this._sessionsManagementService.onDidChangeSessions(e => { if (this.visible) { this.refresh(); } + // A removed session may have been the last one in its workspace. + // Garbage-collect manual order / promotion entries for identities + // that no longer exist. This runs only on removals (never on + // additions or the initial load) so that asynchronous session + // loading on a window reload can never prune the user's manual + // ordering of workspaces relative to groups before their sessions + // have loaded. + if (e.removed.length > 0) { + this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); + } })); this._register(this._sessionsListModelService.onDidChange(() => { @@ -1698,10 +1708,17 @@ export class SessionsList extends Disposable implements ISessionsList { } })); - this._register(this._sessionGroupsService.onDidChange(() => { + this._register(this._sessionGroupsService.onDidChange(e => { if (this.visible) { this.update(); } + // Garbage-collect manual order / promotion entries when groups are + // deleted or evicted. Group changes are user-driven and happen after + // sessions have loaded, so pruning here is safe (unlike at render + // time during the asynchronous initial load). + if (e.groupsChanged) { + this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); + } })); this._register(this._sessionSectionOrderService.onDidChange(() => { @@ -1801,10 +1818,6 @@ export class SessionsList extends Disposable implements ISessionsList { const sorting = this.options.sorting(); const sortKeyForGrouping = (s: ISession, srt: SessionsSorting) => this._sessionsListModelService.getSortKey(s, sortingToMode(srt)); - // Garbage-collect manual order/promotion entries for groups and - // workspaces that no longer exist (does not affect the visible order). - this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); - // Pull regular (non-pinned, non-archived) grouped sessions out of the // normal date/workspace sectioning so they render under their group. // Pinned and archived sessions keep their precedence and stay in their @@ -2314,14 +2327,15 @@ export class SessionsList extends Disposable implements ISessionsList { * The set of top-level reorder identities that currently exist (every group, * plus every workspace label present across all sessions, regardless of * grouping mode or capping). Used to garbage-collect stale manual order and - * promotion entries. + * promotion entries. Reads sessions fresh from the management service so it + * reflects the latest loaded state even when the list is not visible. */ private liveSectionOrderIds(): Set { const ids = new Set(); for (const group of this._sessionGroupsService.getGroups()) { ids.add(`group:${group.id}`); } - for (const session of this.sessions) { + for (const session of this._sessionsManagementService.getSessions()) { ids.add(`workspace:${sessionWorkspaceLabel(session)}`); } return ids; From 54d2211ad438c7d66142c7b53ba0496e591731be Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:56:19 +0000 Subject: [PATCH 41/59] =?UTF-8?q?AgentHost=20-=20=F0=9F=92=84=20clean-up?= =?UTF-8?q?=20operations=20(#323713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/agentHostChangesetOperationService.ts | 6 +++--- .../agentHost/node/agentHostChangesetOperationService.ts | 8 ++++---- .../agentHost/node/agentHostCommitOperationProvider.ts | 6 +++--- .../node/agentHostDiscardChangesOperationProvider.ts | 4 ++-- .../test/node/agentHostChangesetCoordinator.test.ts | 2 +- .../test/node/agentHostCommitOperationProvider.test.ts | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 32a9f737713..6a4a6fdc4f4 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -117,9 +117,9 @@ export interface IAgentHostChangesetOperationService extends IDisposable { updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void; /** - * Returns the operations that should be advertised for the given changeset, or - * `undefined` when no operations are available. - */ + * Returns the operations that should be advertised for the given changeset, or + * `undefined` when no operations are available. + */ getOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined; /** diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 995490df8ad..88ceac5c677 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -49,12 +49,12 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } - getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined { + getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] { if (!gitState) { const sessionState = this._stateManager.getSessionState(sessionKey); gitState = readSessionGitState(sessionState?._meta); if (!gitState) { - return undefined; + return []; } } @@ -64,7 +64,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA const parsed = parseChangesetUri(changeset); if (!parsed) { - return undefined; + return []; } return this._getOperations({ @@ -120,7 +120,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA this._stateManager.dispatchServerAction(changeset, { type: ActionType.ChangesetOperationsChanged, - operations: operations ? [...operations] : undefined, + operations: [...operations], }); } } diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index 51b5a1cd93d..d7eea1afbfa 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -32,13 +32,13 @@ export class AgentHostCommitOperationContribution extends Disposable implements return store; } - getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { if ((gitState?.uncommittedChanges ?? 0) <= 0) { - return undefined; + return []; } if (!gitHubState?.pullRequestUrl && changesetKind !== 'uncommitted') { - return undefined; + return []; } return [{ diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts index 507eef496f0..cb6935a324a 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts @@ -30,9 +30,9 @@ export class AgentHostDiscardChangesOperationContribution extends Disposable imp return store; } - getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] { if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) { - return undefined; + return []; } return [{ diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 4ca135c746e..0c050f2195e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -68,7 +68,7 @@ suite('ChangesetSessionCoordinator', () => { const operationContributionService: IAgentHostChangesetOperationService = { _serviceBrand: undefined, registerContribution: () => Disposable.None, - getOperations: () => undefined, + getOperations: () => [], updateOperations: () => { }, invokeChangesetOperation: async () => ({}), dispose: () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts index 9503efc85dd..863a3694dbc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts @@ -43,7 +43,7 @@ suite('AgentHostCommitOperationContribution', () => { const operations = provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }); - assert.strictEqual(operations, undefined); + assert.deepStrictEqual(operations?.map(op => op.id), []); }); test('advertises commit on the session changeset when there are uncommitted changes', () => { @@ -51,6 +51,6 @@ suite('AgentHostCommitOperationContribution', () => { const operations = provider.getOperations({ sessionKey, changesetUri: buildSessionChangesetUri(sessionKey), changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }); - assert.deepStrictEqual(operations?.map(op => op.id), undefined); + assert.deepStrictEqual(operations?.map(op => op.id), []); }); }); From 6d8b7045d77d2cdca23a0328afd8750466abb95b Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 30 Jun 2026 13:11:11 +0100 Subject: [PATCH 42/59] fix: refactor floating panel margin calculations for improved layout handling --- .../browser/parts/editor/editorPart.ts | 33 ++++++--- .../browser/parts/paneCompositePart.ts | 74 +++++++++++-------- 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index cae14f1864b..4cb9e60f6d7 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1392,17 +1392,8 @@ export class EditorPart extends Part implements IEditorPart, const rightMargin = outerRight ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; width = Math.max(0, width - leftMargin - rightMargin); - // When the panel is positioned above the editor and visible, the editor is no longer - // adjacent to the title bar — reserve a top margin to match the inter-card gaps. - const panelVisible = this.layoutService.isVisible(Parts.PANEL_PART); - const panelAtTop = panelVisible && this.layoutService.getPanelPosition() === Position.TOP; - // When the status bar is hidden, the editor is at the window bottom edge — double the - // margin. Exception: when a bottom panel is visible the editor's bottom faces the panel - // card (not the window edge), so keep the normal inter-card gap. - const panelAtBottom = panelVisible && this.layoutService.getPanelPosition() === Position.BOTTOM; - const bottomEditorMargin = !this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow) && !panelAtBottom - ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; - height = Math.max(0, height - (panelAtTop ? FLOATING_PANEL_MARGIN : 0) - bottomEditorMargin); + const { topMargin, bottomMargin } = this.getFloatingPanelHeightInsets(); + height = Math.max(0, height - topMargin - bottomMargin); // Reserve space for the Modern UI editor border (styleOverrides/media/editorBorder.css) so content doesn't get clipped. if (!this.element.classList.contains('modal-editor-part')) { @@ -1423,6 +1414,26 @@ export class EditorPart extends Part implements IEditorPart, this.doLayout(Dimension.lift(contentAreaSize), top, left); } + /** + * Returns the top and bottom margins (in pixels) to subtract from the editor height + * when the floating panels experiment is active. Accounts for panel position (a top + * panel pushes the editor down) and status bar visibility (hidden status bar means + * the editor is at the window bottom edge and gets a doubled bottom margin). + */ + private getFloatingPanelHeightInsets(): { topMargin: number; bottomMargin: number } { + const panelVisible = this.layoutService.isVisible(Parts.PANEL_PART); + // When the panel is positioned above the editor and visible, the editor is no longer + // adjacent to the title bar — reserve a top margin to match the inter-card gaps. + const panelAtTop = panelVisible && this.layoutService.getPanelPosition() === Position.TOP; + // When the status bar is hidden, the editor is at the window bottom edge — double the + // margin. Exception: when a bottom panel is visible the editor's bottom faces the panel + // card (not the window edge), so keep the normal inter-card gap. + const panelAtBottom = panelVisible && this.layoutService.getPanelPosition() === Position.BOTTOM; + const bottomMargin = !this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow) && !panelAtBottom + ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; + return { topMargin: panelAtTop ? FLOATING_PANEL_MARGIN : 0, bottomMargin }; + } + private doLayout(dimension: Dimension, top = this.top, left = this.left): void { this._contentDimension = dimension; diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 6bdeae1c8d0..0f828260077 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -649,6 +649,48 @@ export abstract class AbstractPaneCompositePart extends CompositePart