From ae05d994043f3fad6cc3992137fa6cc5867e59ee Mon Sep 17 00:00:00 2001 From: Aashna Garg Date: Wed, 8 Apr 2026 12:50:56 -0700 Subject: [PATCH 01/35] Add candidateModel and stickyOverride to automode.routerDecision telemetry The automode.routerDecision event was missing fields to distinguish what model the router recommended vs. what was actually used after sticky-provider and vision overrides. New properties: - candidateModel: the router's top pick (candidate_models[0]) before any same-provider or vision fallback overrides are applied - stickyOverride: whether the router applied a sticky override (1/0) This allows analysts to join automode.routerDecision.candidateModel with response.success.model on conversationId/vscodeRequestId and detect when the router recommended model A but the client used model B. --- .../src/platform/endpoint/node/routerDecisionFetcher.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts index 9a9b45253a721..3f287e69006eb 100644 --- a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts @@ -124,9 +124,11 @@ export class RouterDecisionFetcher { "routingMethod": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The routing method used for this request (empty=server default, binary, hydra). Identifies the A/B/C experiment path." }, "fallback": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the router signaled a fallback to default automod selection." }, "fallbackReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The reason provided by the server when fallback is true." }, + "candidateModel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The top candidate model recommended by the router before any sticky-provider or vision overrides are applied." }, "confidence": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The confidence score of the routing decision" }, "latencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The latency of the router API call in milliseconds" }, - "e2eLatencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The end-to-end latency of the router request in milliseconds, including network overhead" } + "e2eLatencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The end-to-end latency of the router request in milliseconds, including network overhead" }, + "stickyOverride": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the router applied a sticky override (1) or not (0)" } } */ this._telemetryService.sendMSFTTelemetryEvent('automode.routerDecision', @@ -137,11 +139,13 @@ export class RouterDecisionFetcher { routingMethod: result.routing_method ?? '', fallback: String(result.fallback ?? false), fallbackReason: result.fallback_reason ?? '', + candidateModel: result.candidate_models[0] ?? '', }, { confidence: result.confidence, latencyMs: result.latency_ms, e2eLatencyMs: e2eLatencyMs, + stickyOverride: result.sticky_override ? 1 : 0, } ); return result; From 8fbd3ebd5118d55038d06b78d622126f05332c18 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Thu, 9 Apr 2026 17:11:48 -0500 Subject: [PATCH 02/35] Update strings for Upgrades --- extensions/copilot/src/platform/chat/common/commonTypes.ts | 2 +- src/vs/workbench/contrib/chat/browser/actions/chatActions.ts | 2 +- .../contrib/chat/browser/widget/input/chatModelPicker.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index 83c69db6238a9..c869cb511073a 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -292,7 +292,7 @@ function getQuotaHitMessage(fetchResult: ChatFetchError, copilotPlan: string | u if (fetchResult.capiError?.code === 'quota_exceeded') { switch (copilotPlan) { case 'free': - return l10n.t(`You've reached your monthly chat messages quota. Upgrade to Copilot Pro (30-day free trial) or wait for your allowance to renew.`); + return l10n.t(`You've reached your monthly chat messages quota. Upgrade to Copilot Pro or wait for your allowance to renew.`); case 'individual': return l10n.t(`You've exhausted your premium model quota. Please enable additional paid premium requests, upgrade to Copilot Pro+, or wait for your allowance to renew.`); case 'individual_pro': diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 75abd21edf75d..50531ddf9d887 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1192,7 +1192,7 @@ export function registerChatActions() { } const free = chatEntitlementService.entitlement === ChatEntitlement.Free; - const upgradeToPro = free ? localize('upgradeToPro', "Upgrade to GitHub Copilot Pro (your first 30 days are free) for:\n- Unlimited inline suggestions\n- Unlimited chat messages\n- Access to premium models") : undefined; + const upgradeToPro = free ? localize('upgradeToPro', "Upgrade to GitHub Copilot Pro for:\n- Unlimited inline suggestions\n- Unlimited chat messages\n- Access to premium models") : undefined; await dialogService.prompt({ type: 'none', diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index f1569e56c0fdb..4f4c316e2e2a5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -491,7 +491,7 @@ function createUnavailableModelItem( let hoverContent: MarkdownString; if (reason === 'upgrade') { hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") with a free 30-day trial to use the best models.")); + hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); } else if (reason === 'update') { hoverContent = getUpdateHoverContent(updateStateType); } else { From c179fd54f81895588d576f2fd96347648dd1fc5b Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:40:32 -0700 Subject: [PATCH 03/35] Add more correlation ids for external ingest --- .../node/codeSearch/codeSearchChunkSearch.ts | 8 +- .../node/codeSearch/externalIngestIndex.ts | 74 +++++++++++++------ .../node/workspaceChunkSearchService.ts | 4 +- .../test/node/externalIngest.spec.ts | 26 +++---- 4 files changed, 69 insertions(+), 43 deletions(-) diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts index db8e1f37da3e1..61a1cda449c9c 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts @@ -6,7 +6,7 @@ import * as l10n from '@vscode/l10n'; import { shouldInclude } from '../../../../util/common/glob'; import { Result } from '../../../../util/common/result'; -import { CallTracker, TelemetryCorrelationId } from '../../../../util/common/telemetryCorrelationId'; +import { TelemetryCorrelationId } from '../../../../util/common/telemetryCorrelationId'; import { coalesce } from '../../../../util/vs/base/common/arrays'; import { raceCancellationError, raceTimeout } from '../../../../util/vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; @@ -840,7 +840,7 @@ export class CodeSearchChunkSearch extends Disposable { // Update external ingest index if enabled const externalIndexEnabled = this.isExternalIngestEnabled(); if (externalIndexEnabled) { - const result = await raceCancellationError(this._externalIngestIndex.value.doIngest(telemetryInfo.callTracker, onProgress, token), token); + const result = await raceCancellationError(this._externalIngestIndex.value.doIngest(telemetryInfo, onProgress, token), token); if (result.isError()) { return Result.error(result.err); } @@ -978,7 +978,7 @@ export class CodeSearchChunkSearch extends Disposable { return undefined; } - public deleteExternalIngestWorkspaceIndex(callTracker: CallTracker, token: CancellationToken): Promise { - return this._externalIngestIndex.value.deleteIndex(callTracker, token); + public deleteExternalIngestWorkspaceIndex(telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + return this._externalIngestIndex.value.deleteIndex(telemetryInfo, token); } } diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts index 47767a775da92..8619ea64b029b 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import sql from 'node:sqlite'; import { toErrorMessage } from '../../../../util/common/errorMessage'; import { Result } from '../../../../util/common/result'; -import { CallTracker, TelemetryCorrelationId } from '../../../../util/common/telemetryCorrelationId'; +import { TelemetryCorrelationId } from '../../../../util/common/telemetryCorrelationId'; import { coalesce } from '../../../../util/vs/base/common/arrays'; import { CancelablePromise, createCancelablePromise, Limiter, raceCancellationError, timeout } from '../../../../util/vs/base/common/async'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; @@ -206,7 +206,7 @@ export class ExternalIngestIndex extends Disposable { * This deletes the remote file set and the checkpoint. We keep around the local database because it * has a cache of file shas. */ - public async deleteIndex(callTracker: CallTracker, token: CancellationToken): Promise { + public async deleteIndex(telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { const filesetName = this.getFilesetName(); if (!filesetName) { return; @@ -214,7 +214,7 @@ export class ExternalIngestIndex extends Disposable { this._logService.info(`ExternalIngestIndex: Deleting index for fileset ${filesetName}`); try { - await this._client.deleteFileset(filesetName, callTracker, token); + await this._client.deleteFileset(filesetName, telemetryInfo.callTracker, token); this.clearCurrentIndexCheckpoint(); this._onDidChangeState.fire(); @@ -223,19 +223,30 @@ export class ExternalIngestIndex extends Disposable { /* __GDPR__ "externalIngestIndex.deleteIndex" : { "owner": "mjbvz", - "comment": "Logged when external ingest index is deleted successfully" + "comment": "Logged when external ingest index is deleted successfully", + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the operation" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the operation" } } */ - this._telemetryService.sendMSFTTelemetryEvent('externalIngestIndex.deleteIndex'); + this._telemetryService.sendMSFTTelemetryEvent('externalIngestIndex.deleteIndex', { + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }); } catch (e) { /* __GDPR__ "externalIngestIndex.deleteIndex.error" : { "owner": "mjbvz", "comment": "Logged when deleting external ingest index fails", - "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The error message" } + "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The error message" }, + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the operation" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the operation" } } */ - this._telemetryService.sendMSFTTelemetryErrorEvent('externalIngestIndex.deleteIndex.error', { error: (e as Error).message }); + this._telemetryService.sendMSFTTelemetryErrorEvent('externalIngestIndex.deleteIndex.error', { + error: (e as Error).message, + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }); throw e; } } @@ -301,7 +312,7 @@ export class ExternalIngestIndex extends Disposable { return this._initializePromise; } - async doIngest(callTracker: CallTracker, onProgress: (message: string) => void, callerToken: CancellationToken): Promise> { + async doIngest(telemetryInfo: TelemetryCorrelationId, onProgress: (message: string) => void, callerToken: CancellationToken): Promise> { await raceCancellationError(this.initialize(), callerToken); const filesetName = this.getFilesetName(); @@ -337,7 +348,7 @@ export class ExternalIngestIndex extends Disposable { filesetName, currentCheckpoint, this.getFilesToIndexFromDb(token), - callTracker, + telemetryInfo.callTracker, token, wrappedOnProgress ); @@ -348,12 +359,17 @@ export class ExternalIngestIndex extends Disposable { "externalIngestIndex.updateIndex.success" : { "owner": "mjbvz", "comment": "Logged when external ingest index update completes successfully", - "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time taken to complete the update in milliseconds" }, - "totalFileCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of files in the index" }, - "updatedFileCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of files that were updated" } - } - */ - this._telemetryService.sendMSFTTelemetryEvent('externalIngestIndex.updateIndex.success', undefined, { durationMs: sw.elapsed(), totalFileCount: result.val.totalFileCount, updatedFileCount: result.val.updatedFileCount }); +"workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the operation" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the operation" }, + "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time taken to complete the update in milliseconds" }, + "totalFileCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of files in the index" }, + "updatedFileCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of files that were updated" } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('externalIngestIndex.updateIndex.success', { + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }, { durationMs: sw.elapsed(), totalFileCount: result.val.totalFileCount, updatedFileCount: result.val.updatedFileCount }); return Result.ok(true); } else { @@ -362,11 +378,16 @@ export class ExternalIngestIndex extends Disposable { "owner": "mjbvz", "comment": "Logged when external ingest index update fails", "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The error message" }, - "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time taken before failure in milliseconds" } - } - */ - this._telemetryService.sendMSFTTelemetryErrorEvent('externalIngestIndex.updateIndex.error', { error: result.err.message }, { durationMs: sw.elapsed() }); - + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the operation" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the operation" }, + "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time taken before failure in milliseconds" } + } + */ + this._telemetryService.sendMSFTTelemetryErrorEvent('externalIngestIndex.updateIndex.error', { + error: result.err.message, + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }, { durationMs: sw.elapsed() }); return Result.error({ id: 'external-ingest-error', userMessage: l10n.t("Failed to update external ingest index: {0}", result.err.message) @@ -382,11 +403,16 @@ export class ExternalIngestIndex extends Disposable { "owner": "mjbvz", "comment": "Logged when external ingest index update throws an exception", "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "The exception message" }, - "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time taken before exception in milliseconds" } + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the operation" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the operation" }, + "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time taken before exception in milliseconds" } } */ - this._telemetryService.sendMSFTTelemetryErrorEvent('externalIngestIndex.updateIndex.exception', { error: (e as Error).message }, { durationMs: sw.elapsed() }); - + this._telemetryService.sendMSFTTelemetryErrorEvent('externalIngestIndex.updateIndex.exception', { + error: (e as Error).message, + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }, { durationMs: sw.elapsed() }); return Result.error({ id: 'external-ingest-error', userMessage: l10n.t("Exception updating external ingest index: {0}", (e as Error).message) @@ -421,7 +447,7 @@ export class ExternalIngestIndex extends Disposable { try { const resolvedQuery = query.queryText; - const ingestResult = await raceCancellationError(this.doIngest(callTracker, () => { }, token), token); + const ingestResult = await raceCancellationError(this.doIngest(telemetryInfo, () => { }, token), token); if (!ingestResult.isOk()) { return undefined; } diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts index 7b2eb38cba1c9..abd24a2088299 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts @@ -8,7 +8,7 @@ import type * as vscode from 'vscode'; import { createFencedCodeBlock, getLanguageId } from '../../../util/common/markdown'; import { Result } from '../../../util/common/result'; import { createServiceIdentifier } from '../../../util/common/services'; -import { CallTracker, TelemetryCorrelationId } from '../../../util/common/telemetryCorrelationId'; +import { TelemetryCorrelationId } from '../../../util/common/telemetryCorrelationId'; import { coalesce } from '../../../util/vs/base/common/arrays'; import { raceCancellationError } from '../../../util/vs/base/common/async'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; @@ -254,7 +254,7 @@ class WorkspaceChunkSearchServiceImpl extends Disposable implements IWorkspaceCh deleteExternalIngestWorkspaceIndex(): Promise { return this._codeSearchChunkSearch.deleteExternalIngestWorkspaceIndex( - new CallTracker('WorkspaceChunkSearchService::deleteExternalIngestWorkspaceIndex'), + new TelemetryCorrelationId('WorkspaceChunkSearchService::deleteExternalIngestWorkspaceIndex'), CancellationToken.None); } diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts index 02c17e3506562..68d06877f71df 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { afterEach, beforeEach, suite, test, vi } from 'vitest'; import type { FileSystemWatcher } from 'vscode'; import { Result } from '../../../../util/common/result'; -import { CallTracker } from '../../../../util/common/telemetryCorrelationId'; +import { CallTracker, TelemetryCorrelationId } from '../../../../util/common/telemetryCorrelationId'; import { mock } from '../../../../util/common/test/simpleMock'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; @@ -23,7 +23,7 @@ import { ExternalIngestClient, ExternalIngestFile, ExternalIngestUpdateIndexResu import { ExternalIngestIndex } from '../../node/codeSearch/externalIngestIndex'; const emptyProgressCb: (message: string) => void = () => { }; -const testCallTracker = new CallTracker('externalIngest.spec.ts'); +const testTelemetryInfo = new TelemetryCorrelationId('externalIngest.spec.ts'); function createMockExternalIngestClient(options?: { canIngestPathAndSize?: (filePath: string, size: number) => boolean; @@ -338,7 +338,7 @@ suite('ExternalIngestIndex', () => { const { mockClient, index } = setupTestContext(workspaceRoot, files); await index.initialize(); - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); // Verify that both files were passed to the client for ingestion assert.strictEqual(mockClient.ingestedFiles.length, 2, 'Both files should be ingested'); @@ -365,7 +365,7 @@ suite('ExternalIngestIndex', () => { }); await index.initialize(); - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); // Only the small file should be ingested (large file fails canIngestPathAndSize) assert.strictEqual(mockClient.ingestedFiles.length, 1, 'Only small file should be ingested'); @@ -393,7 +393,7 @@ suite('ExternalIngestIndex', () => { }); await index.initialize(); - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); // Only the text file should be ingested (binary file fails canIngestDocument) assert.strictEqual(mockClient.ingestedFiles.length, 1, 'Only text file should be ingested'); @@ -419,7 +419,7 @@ suite('ExternalIngestIndex', () => { }); await index.initialize(); - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); // Only the source file should be ingested (vendor file filtered by path pattern) assert.strictEqual(mockClient.ingestedFiles.length, 1, 'Only source file should be ingested'); @@ -442,11 +442,11 @@ suite('ExternalIngestIndex', () => { await index.initialize(); // First ingest - file should be read - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); assert.ok(mockFs.countReadFileCalls(file1) >= 1, 'File should be read during first ingest'); // Second ingest - file should NOT be re-read since mtime unchanged - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); // The file should still be yielded from the ingestion assert.strictEqual(mockClient.ingestedFiles.length, 1, 'File should still be yielded on second ingest'); @@ -465,7 +465,7 @@ suite('ExternalIngestIndex', () => { const { mockFs, mockClient, index } = setupTestContext(workspaceRoot, files); await index.initialize(); - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); assert.strictEqual(mockClient.ingestedFiles.length, 1, 'File should be yielded on first ingest'); assert.strictEqual(mockFs.countReadFileCalls(file1), 1, 'File should be read once during first ingest'); @@ -474,7 +474,7 @@ suite('ExternalIngestIndex', () => { files.set(file1, createFileFromString('const x = 2;', 2000)); // Second ingest after file change - file SHOULD be re-read - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); assert.strictEqual(mockFs.countReadFileCalls(file1), 2, 'File SHOULD be re-read when mtime changes'); }); @@ -495,12 +495,12 @@ suite('ExternalIngestIndex', () => { await index.initialize(); // First ingest - all files should be read - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); assert.strictEqual(mockClient.ingestedFiles.length, 3, 'All files should be ingested'); // Second ingest, should not trigger any new reads - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); // All files should be yielded but none should be re-read for docSha computation assert.strictEqual(mockClient.ingestedFiles.length, 3, 'All files should still be yielded'); @@ -510,7 +510,7 @@ suite('ExternalIngestIndex', () => { files.set(file2, createFileFromString('const y = 999;', 2000)); // Third ingest - only file2 should be re-read - assert.ok((await index.doIngest(testCallTracker, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); + assert.ok((await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None)).isOk(), 'Ingest should complete successfully'); assert.strictEqual(mockClient.ingestedFiles.length, 3, 'All files should still be yielded'); assert.strictEqual(mockFs.countReadFileCalls(file2), 2, 'Changed file should be re-read'); From 8a98e1ed4ebeba3c0fd49c0675114629f9777c2c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:51:35 +0000 Subject: [PATCH 04/35] Merge pull request #309037 from microsoft/copilot/fix-test-coverage-toolbar-icon fix: Coverage toolbar button missing profile picker dropdown --- .../contrib/testing/browser/testingExplorerView.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 9257aa6588f0e..20d4dbdb8d26a 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -318,6 +318,8 @@ export class TestingExplorerView extends ViewPane { return this.getRunGroupDropdown(TestRunProfileBitset.Run, action, options); case TestCommandId.DebugSelectedAction: return this.getRunGroupDropdown(TestRunProfileBitset.Debug, action, options); + case TestCommandId.CoverageSelectedAction: + return this.getRunGroupDropdown(TestRunProfileBitset.Coverage, action, options); case TestCommandId.StartContinousRun: case TestCommandId.StopContinousRun: return this.getContinuousRunDropdown(action, options); @@ -431,7 +433,9 @@ export class TestingExplorerView extends ViewPane { title: defaultAction.label, icon: group === TestRunProfileBitset.Run ? icons.testingRunAllIcon - : icons.testingDebugAllIcon, + : group === TestRunProfileBitset.Debug + ? icons.testingDebugAllIcon + : icons.testingCoverageAllIcon, }, undefined, undefined, undefined, undefined); return this.instantiationService.createInstance( From 88e21a0445a286ceb0b5061c17acc94cd42103c8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 10 Apr 2026 19:02:02 +0200 Subject: [PATCH 05/35] handle edge cases while updating built in extensions (#308991) * handle edge cases while updating built in extensions manually * Update src/vs/platform/extensionManagement/node/extensionManagementService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix compilation --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../node/extensionManagementService.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 1963eb4b31643..6eefd7163dbb1 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -1056,6 +1056,7 @@ class InstallExtensionInProfileTask extends AbstractExtensionTask areSameExtensions(i.identifier, this.identifier)); + if (existingSystemExtension) { + if (!existingSystemExtension.forceAutoUpdate) { + throw new ExtensionManagementError(nls.localize('builtinAutoUpdate', "Extension '{0}' is a built-in extension and not allowed to be updated in the current product quality '{1}'.", existingSystemExtension.identifier.id, this.productService.quality), ExtensionManagementErrorCode.Incompatible); + } + if (semver.gt(existingSystemExtension.manifest.version, this.manifest.version)) { + throw new ExtensionManagementError(nls.localize('builtinVersion', "Extension '{0}' is a built-in extension with version '{1}' and cannot be downgraded to version '{2}'.", existingSystemExtension.identifier.id, existingSystemExtension.manifest.version, this.manifest.version), ExtensionManagementErrorCode.Incompatible); + } + } + const metadata: Metadata = { isApplicationScoped: this.options.isApplicationScoped || existingExtension?.isApplicationScoped, isMachineScoped: this.options.isMachineScoped || existingExtension?.isMachineScoped, @@ -1092,6 +1104,7 @@ class InstallExtensionInProfileTask extends AbstractExtensionTask Date: Sat, 11 Apr 2026 02:22:54 +0900 Subject: [PATCH 06/35] Revert "feat: enable agents app for stable" (#309055) Revert "feat: enable agents app for stable (#308650)" This reverts commit b5dd5ac449007e5be7e0f796a3abe24c5b243413. --- build/gulpfile.vscode.ts | 7 ++++--- build/gulpfile.vscode.win32.ts | 3 ++- src/vs/code/electron-main/app.ts | 4 ++-- src/vs/platform/launch/electron-main/launchMainService.ts | 4 +++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 96e26071500b8..25ba5e88ad8af 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -393,7 +393,8 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d })); - const embedded = quality + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const embedded = isInsiderOrExploration ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded : undefined; @@ -561,8 +562,8 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d '**', '!LICENSE', '!version', - ...(platform === 'darwin' && !quality ? ['!**/Contents/Applications', '!**/Contents/Applications/**'] : []), - ...(platform === 'win32' && !quality ? ['!**/electron_proxy.exe'] : []), + ...(platform === 'darwin' && !isInsiderOrExploration ? ['!**/Contents/Applications', '!**/Contents/Applications/**'] : []), + ...(platform === 'win32' && !isInsiderOrExploration ? ['!**/electron_proxy.exe'] : []), ], { dot: true })); if (platform === 'linux') { diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index a634eb4c68bbd..7a711276be59d 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -112,7 +112,8 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { Quality: quality }; - const embedded = quality + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const embedded = isInsiderOrExploration ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded : undefined; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index d69e9d0e4fb39..1e5fd2bfeae2b 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -1368,7 +1368,7 @@ export class CodeApplication extends Disposable { const args = this.environmentMainService.args; // Handle agents window first based on context - if ((process as INodeProcess).isEmbeddedApp || args['agents']) { + if ((process as INodeProcess).isEmbeddedApp || (args['agents'] && this.productService.quality !== 'stable')) { return windowsMainService.openAgentsWindow({ context, cli: args, @@ -1750,7 +1750,7 @@ export class CodeApplication extends Disposable { } private registerEmbeddedAppWithLaunchServices(): void { - if (!isMacintosh || (process as INodeProcess).isEmbeddedApp || !this.productService.embedded?.nameShort) { + if (!isMacintosh || (process as INodeProcess).isEmbeddedApp || !this.productService.embedded?.nameShort || this.productService.quality === 'stable') { return; } diff --git a/src/vs/platform/launch/electron-main/launchMainService.ts b/src/vs/platform/launch/electron-main/launchMainService.ts index 1984c46f0f93f..b45f24b688911 100644 --- a/src/vs/platform/launch/electron-main/launchMainService.ts +++ b/src/vs/platform/launch/electron-main/launchMainService.ts @@ -18,6 +18,7 @@ import { ICodeWindow } from '../../window/electron-main/window.js'; import { IWindowSettings } from '../../window/common/window.js'; import { IOpenConfiguration, IWindowsMainService, OpenContext } from '../../windows/electron-main/windows.js'; import { IProtocolUrl } from '../../url/electron-main/url.js'; +import { IProductService } from '../../product/common/productService.js'; export const ID = 'launchMainService'; export const ILaunchMainService = createDecorator(ID); @@ -45,6 +46,7 @@ export class LaunchMainService implements ILaunchMainService { @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IURLService private readonly urlService: IURLService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IProductService private readonly productService: IProductService, ) { } async start(args: NativeParsedArgs, userEnv: IProcessEnvironment): Promise { @@ -144,7 +146,7 @@ export class LaunchMainService implements ILaunchMainService { } // Agents window - else if (args['agents']) { + else if (args['agents'] && this.productService.quality !== 'stable') { usedWindows = await this.windowsMainService.openAgentsWindow(baseConfig); } From 46933de594b9d221c009f6cac2ad28317fbac4a6 Mon Sep 17 00:00:00 2001 From: Yogeshwaran C <84272111+yogeshwaran-c@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:14:51 +0530 Subject: [PATCH 07/35] Merge pull request #308925 from yogeshwaran-c/fix/debug-repl-copy-source-annotation fix: exclude source annotations from text selection in debug console --- src/vs/workbench/contrib/debug/browser/media/repl.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/debug/browser/media/repl.css b/src/vs/workbench/contrib/debug/browser/media/repl.css index 6aad626c79f0d..e5f3fcb774600 100644 --- a/src/vs/workbench/contrib/debug/browser/media/repl.css +++ b/src/vs/workbench/contrib/debug/browser/media/repl.css @@ -89,6 +89,8 @@ /*Use direction so the source shows elipses on the left*/ direction: rtl; max-width: 400px; + user-select: none; + -webkit-user-select: none; } .monaco-workbench .repl .repl-tree .output.expression > .value, From 72cfebb6ffada6ae87d98440dddeed88b9968cd8 Mon Sep 17 00:00:00 2001 From: Winston Liu Date: Fri, 10 Apr 2026 10:56:27 -0700 Subject: [PATCH 08/35] Fix --prof-startup never being able to profile renderer/extension host (#307849) * Pass only the port to --remote-debugging-port and --inspect-brk-extensions * Also remove host from --inspect-brk --- src/vs/code/node/cli.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 22350b002d906..7255ce533803a 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -385,9 +385,9 @@ export async function main(argv: string[]): Promise { const filenamePrefix = randomPath(homedir(), 'prof'); - addArg(argv, `--inspect-brk=${profileHost}:${portMain}`); - addArg(argv, `--remote-debugging-port=${profileHost}:${portRenderer}`); - addArg(argv, `--inspect-brk-extensions=${profileHost}:${portExthost}`); + addArg(argv, `--inspect-brk=${portMain}`); + addArg(argv, `--remote-debugging-port=${portRenderer}`); + addArg(argv, `--inspect-brk-extensions=${portExthost}`); addArg(argv, `--prof-startup-prefix`, filenamePrefix); addArg(argv, `--no-cached-data`); From f5c39cbc25f27b14c35d6374d25f40a527599469 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:00:39 -0700 Subject: [PATCH 09/35] sessions: show restore button instead of checkmark for done sessions (#309058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sessions: show restore button instead of checkmark for done sessions When a session is already marked as done, the checkmark button in the command center was still shown but did nothing on click. Now: - Hide the 'Mark as Done' checkmark when the active session is archived - Show the existing 'Restore' button (discard icon) in its place - Track active session archived state via IsActiveSessionArchivedContext Fixes #307712 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: track previous archived state for false→true transitions Use a mutable wasArchived variable instead of a constant so that openNewSessionView() fires correctly on restore→re-archive flows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/common/contextkeys.ts | 1 + .../browser/views/sessionsViewActions.ts | 20 +++++++++++++--- .../browser/sessionsManagementService.ts | 23 +++++++++++-------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index dd02a136de7e9..42b77fafa180a 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -12,6 +12,7 @@ export const IsNewChatSessionContext = new RawContextKey('isNewChatSess export const ActiveSessionProviderIdContext = new RawContextKey('activeSessionProviderId', '', localize('activeSessionProviderId', "The provider ID of the active session")); export const ActiveSessionTypeContext = new RawContextKey('activeSessionType', '', localize('activeSessionType', "The session type of the active session")); export const IsActiveSessionBackgroundProviderContext = new RawContextKey('isActiveSessionBackgroundProvider', false, localize('isActiveSessionBackgroundProvider', "Whether the active session uses the background agent provider")); +export const IsActiveSessionArchivedContext = new RawContextKey('isActiveSessionArchived', false, localize('isActiveSessionArchived', "Whether the active session is archived (marked as done)")); export const ActiveSessionHasGitRepositoryContext = new RawContextKey('activeSessionHasGitRepository', false, localize('activeSessionHasGitRepository', "Whether the active session has an associated git repository")); export const ChatSessionProviderIdContext = new RawContextKey('chatSessionProviderId', '', localize('chatSessionProviderId', "The provider ID of a session in context menu overlays")); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 0c06ef7d85b4f..0d3e5c8cb6b6a 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -18,7 +18,7 @@ import { EditorsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContex import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { AUX_WINDOW_GROUP } from '../../../../../workbench/services/editor/common/editorService.js'; import { SessionsCategories } from '../../../../common/categories.js'; -import { ChatSessionProviderIdContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; +import { ChatSessionProviderIdContext, IsActiveSessionArchivedContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, IsSessionArchivedContext, IsSessionReadContext, SessionsGrouping, SessionsSorting, ISessionSection } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext } from './sessionsView.js'; @@ -502,15 +502,28 @@ registerAction2(class UnarchiveSessionAction extends Action2 { group: '1_edit', order: 2, when: ContextKeyExpr.equals(IsSessionArchivedContext.key, true), + }, { + id: Menus.CommandCenter, + order: 103, + when: ContextKeyExpr.and( + IsAuxiliaryWindowContext.negate(), + SessionsWelcomeVisibleContext.negate(), + IsNewChatSessionContext.negate(), + IsActiveSessionArchivedContext + ) }] }); } async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); if (!context) { + const activeSession = sessionsManagementService.activeSession.get(); + if (activeSession) { + await sessionsManagementService.unarchiveSession(activeSession); + } return; } const sessions = Array.isArray(context) ? context : [context]; - const sessionsManagementService = accessor.get(ISessionsManagementService); for (const session of sessions) { await sessionsManagementService.unarchiveSession(session); } @@ -680,7 +693,8 @@ registerAction2(class MarkSessionAsDoneAction extends Action2 { when: ContextKeyExpr.and( IsAuxiliaryWindowContext.negate(), SessionsWelcomeVisibleContext.negate(), - IsNewChatSessionContext.negate() + IsNewChatSessionContext.negate(), + IsActiveSessionArchivedContext.negate() ) }, { diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index f1ee498e9ec50..044b1abe25355 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -12,7 +12,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionBackgroundProviderContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; +import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionArchivedContext, IsActiveSessionBackgroundProviderContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { ActiveSessionSupportsMultiChatContext, IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; @@ -43,6 +43,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen private readonly _activeSessionProviderId: IContextKey; private readonly _activeSessionType: IContextKey; private readonly _isBackgroundProvider: IContextKey; + private readonly _isActiveSessionArchived: IContextKey; private readonly _supportsMultiChat: IContextKey; private _activeChatObservable: ISettableObservable | undefined; private _activeSessionDisposables = this._register(new DisposableStore()); @@ -64,6 +65,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._activeSessionProviderId = ActiveSessionProviderIdContext.bindTo(contextKeyService); this._activeSessionType = ActiveSessionTypeContext.bindTo(contextKeyService); this._isBackgroundProvider = IsActiveSessionBackgroundProviderContext.bindTo(contextKeyService); + this._isActiveSessionArchived = IsActiveSessionArchivedContext.bindTo(contextKeyService); this._supportsMultiChat = ActiveSessionSupportsMultiChatContext.bindTo(contextKeyService); // Load last selected session @@ -327,6 +329,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._activeSessionProviderId.set(session?.providerId ?? ''); this._activeSessionType.set(session?.sessionType ?? ''); this._isBackgroundProvider.set(session?.sessionType === COPILOT_CLI_SESSION_TYPE); + this._isActiveSessionArchived.set(session?.isArchived.get() ?? false); const provider = session ? this.sessionsProvidersService.getProviders().find(p => p.id === session.providerId) : undefined; this._supportsMultiChat.set(provider?.capabilities.multipleChatsPerSession ?? false); @@ -353,14 +356,16 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._activeSession.set(activeSession, undefined); - // Listen for the active session becoming archived - if (!session.isArchived.get()) { - this._activeSessionDisposables.add(autorun(reader => { - if (session.isArchived.read(reader)) { - this.openNewSessionView(); - } - })); - } + // Track archived state changes for the active session + let wasArchived = session.isArchived.get(); + this._activeSessionDisposables.add(autorun(reader => { + const isArchived = session.isArchived.read(reader); + this._isActiveSessionArchived.set(isArchived); + if (isArchived && !wasArchived) { + this.openNewSessionView(); + } + wasArchived = isArchived; + })); } else { this._activeChatObservable = undefined; this._activeSession.set(undefined, undefined); From bb77eceb5c6c03730f346c1633706793ddac832c Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 10 Apr 2026 14:09:18 -0400 Subject: [PATCH 10/35] add terminal button to questions tool, detailed progress messages, hide when input happens (#309050) --- .../chatQuestionCarouselPart.ts | 42 ++++++ .../media/chatQuestionCarousel.css | 19 +++ .../chat/browser/widget/chatListRenderer.ts | 15 ++ .../chat/common/chatService/chatService.ts | 9 ++ .../common/chatService/chatServiceImpl.ts | 15 +- .../contrib/chat/common/model/chatModel.ts | 33 ++++- .../chatQuestionCarouselData.ts | 7 + .../common/model/chatSessionOperationLog.ts | 1 + .../tools/builtinTools/askQuestionsTool.ts | 62 +++++++- .../chat/common/tools/terminalToolIds.ts | 25 ++++ .../ChatService_can_deserialize.0.snap | 1 + ...rvice_can_deserialize_with_response.0.snap | 1 + .../ChatService_can_serialize.1.snap | 2 + .../ChatService_sendRequest_fails.0.snap | 1 + .../terminal/terminalContribChatExports.ts | 1 + .../browser/tools/runInTerminalTool.ts | 79 +++++++++- .../browser/tools/sendToTerminalTool.ts | 139 +++++++++++++++++- .../chatAgentTools/browser/tools/toolIds.ts | 15 +- .../runInTerminalTool.test.ts | 2 + 19 files changed, 442 insertions(+), 27 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/tools/terminalToolIds.ts diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index e446839cfed65..abb5234d7dc4e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -33,6 +33,8 @@ import { IContextKey, IContextKeyService } from '../../../../../../platform/cont import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { RunInTerminalTool } from '../../../../terminal/terminalContribChatExports.js'; import './media/chatQuestionCarousel.css'; const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion'; @@ -87,6 +89,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private readonly _inChatQuestionCarouselContextKey: IContextKey; private _validationMessageElement: HTMLElement | undefined; private _currentValidationError: string | undefined; + private _focusTerminalButtonContainer: HTMLElement | undefined; constructor( public readonly carousel: IChatQuestionCarousel, @@ -97,6 +100,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IKeybindingService private readonly _keybindingService: IKeybindingService, + @ICommandService private readonly _commandService: ICommandService, ) { super(); @@ -175,6 +179,32 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._skipAllButton = skipAllButton; } + // Focus Terminal button - shown when the carousel was triggered by terminal input + if (carousel.terminalId) { + this._focusTerminalButtonContainer = dom.$('.chat-question-focus-terminal-container'); + const focusTerminalTitle = localize('chat.questionCarousel.focusTerminalTitle', 'Focus Terminal'); + const focusTerminalButton = interactiveStore.add(new Button(this._focusTerminalButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + focusTerminalButton.label = `$(${Codicon.terminal.id})`; + focusTerminalButton.element.classList.add('chat-question-focus-terminal'); + focusTerminalButton.element.setAttribute('aria-label', focusTerminalTitle); + interactiveStore.add(this._hoverService.setupDelayedHover(focusTerminalButton.element, { content: focusTerminalTitle })); + interactiveStore.add(focusTerminalButton.onDidClick(() => this._focusTerminal())); + + // Dismiss the carousel when the user types directly in the terminal, + // since they are answering the prompt themselves. + const execution = RunInTerminalTool.getExecution(carousel.terminalId); + if (execution) { + interactiveStore.add(execution.instance.onDidInputData(() => { + if (!this._isSkipped) { + if (carousel instanceof ChatQuestionCarouselData) { + carousel.dismissedByTerminalInput = true; + } + this.ignore(); + } + })); + } + } + // Register event listeners interactiveStore.add(collapseButton.onDidClick(() => this.toggleCollapsed())); @@ -257,6 +287,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._onDidChangeHeight.fire(); } + private _focusTerminal(): void { + const terminalId = this.carousel.terminalId; + if (!terminalId) { + return; + } + this._commandService.executeCommand('workbench.action.terminal.chat.focusTerminalByExecutionId', terminalId); + } + private updateCollapsedPresentation(): void { this.domNode.classList.toggle('chat-question-carousel-collapsed', this._isCollapsed); @@ -385,6 +423,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._questionContainer = undefined; this._headerActionsContainer = undefined; this._closeButtonContainer = undefined; + this._focusTerminalButtonContainer = undefined; this._collapseButton = undefined; this._footerRow = undefined; this._stepIndicator = undefined; @@ -661,6 +700,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (this._headerActionsContainer) { dom.clearNode(this._headerActionsContainer); + if (this._focusTerminalButtonContainer) { + this._headerActionsContainer.appendChild(this._focusTerminalButtonContainer); + } if (this._closeButtonContainer) { this._headerActionsContainer.appendChild(this._closeButtonContainer); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 656af1f8bf199..e941918d96dc7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -106,6 +106,25 @@ flex-shrink: 0; } + .chat-question-focus-terminal-container { + flex-shrink: 0; + + .monaco-button.chat-question-focus-terminal { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-icon-foreground) !important; + } + + .monaco-button.chat-question-focus-terminal:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } + } + .chat-question-close-container { flex-shrink: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 16ddb589eeecd..7485a5b8f0a84 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2432,6 +2432,21 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer r.id === responseElement.requestId); + if (request?.terminalExecutionId) { + carousel.terminalId = request.terminalExecutionId; + this.logService.trace(`ChatListItemRenderer#renderQuestionCarousel: backfilled terminalId=${carousel.terminalId} for request=${responseElement.requestId}`); + } else { + this.logService.trace(`ChatListItemRenderer#renderQuestionCarousel: no terminalExecutionId to backfill for request=${responseElement.requestId}`); + } + } + const widget = isResponseVM(context.element) ? this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource) : undefined; const shouldAutoFocus = widget ? widget.getInput() === '' : true; const responseId = isResponseVM(context.element) ? context.element.requestId : undefined; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index f1c564c7e993a..288db4b0d80b2 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -422,6 +422,8 @@ export interface IChatQuestionCarousel { message?: string | IMarkdownString; /** Source attribution (e.g. MCP server) */ source?: ToolDataSource; + /** Terminal ID when the carousel was triggered by a terminal needing input */ + terminalId?: string; kind: 'questionCarousel'; } @@ -1426,6 +1428,13 @@ export interface IChatSendRequestOptions { */ systemInitiatedLabel?: string; + /** + * Structured terminal execution ID for system-initiated terminal notifications. + * This avoids parsing IDs from request text when tools need to correlate + * terminal prompts with follow-up actions. + */ + terminalExecutionId?: string; + /** * When set, the chat service will collect automatic instructions * (for example `.instructions.md` files and skills) asynchronously after showing diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 5b010fe32d55f..fa1968b9231fc 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -830,6 +830,7 @@ export class ChatService extends Disposable implements IChatService { userSelectedTools: options.userSelectedTools?.get(), isSystemInitiated: options.isSystemInitiated, systemInitiatedLabel: options.systemInitiatedLabel, + terminalExecutionId: options.terminalExecutionId, }); const deferred = new DeferredPromise(); @@ -1154,7 +1155,7 @@ export class ChatService extends Disposable implements IChatService { const initialAgent = agentPart?.agent ?? defaultAgent; const initialCommand = agentSlashCommandPart?.command; const initVariableData: IChatRequestVariableData = { variables: [] }; - request = model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, initialAgent, initialCommand, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), undefined, options?.isSystemInitiated, options?.systemInitiatedLabel); + request = model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, initialAgent, initialCommand, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), undefined, options?.isSystemInitiated, options?.systemInitiatedLabel, options?.terminalExecutionId); const thisRequest = request; completeResponseCreated(); @@ -1483,8 +1484,20 @@ export class ChatService extends Disposable implements IChatService { // Build send options from the first request, combining attachments from all const firstRequest = allRequests[0]; + + // Preserve terminal correlation only when all merged requests agree on the + // same terminal. With subagents, multiple terminals can queue steering + // requests simultaneously — picking one arbitrarily would misattribute the + // notification, so we drop the ID when they conflict. + const terminalIds = new Set(allRequests.map(req => req.sendOptions.terminalExecutionId).filter((id): id is string => !!id)); + if (terminalIds.size > 1) { + this.info('processNextPendingRequest', `Dropping terminalExecutionId: ${terminalIds.size} conflicting terminal IDs (${[...terminalIds].join(', ')})`); + } + const mergedTerminalExecutionId = terminalIds.size === 1 ? [...terminalIds][0] : undefined; + const sendOptions: IChatSendRequestOptions = { ...firstRequest.sendOptions, + terminalExecutionId: mergedTerminalExecutionId, attachedContext: allRequests.flatMap(req => req.request.variableData.variables.slice()), }; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index b02a9a3fc5de1..879991baa7ed6 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -72,6 +72,9 @@ export interface ISerializableSendOptions { agentIdSilent?: string; slashCommand?: string; confirmation?: string; + isSystemInitiated?: boolean; + systemInitiatedLabel?: string; + terminalExecutionId?: string; } /** @@ -128,6 +131,7 @@ export interface IChatRequestModel { readonly userSelectedTools?: UserSelectedTools; readonly isSystemInitiated?: boolean; readonly systemInitiatedLabel?: string; + readonly terminalExecutionId?: string; } export interface ICodeBlockInfo { @@ -352,6 +356,7 @@ export interface IChatRequestModelParameters { userSelectedTools?: UserSelectedTools; isSystemInitiated?: boolean; systemInitiatedLabel?: string; + terminalExecutionId?: string; } export class ChatRequestModel implements IChatRequestModel { @@ -366,6 +371,7 @@ export class ChatRequestModel implements IChatRequestModel { public readonly userSelectedTools?: UserSelectedTools; public readonly isSystemInitiated?: boolean; public readonly systemInitiatedLabel?: string; + public readonly terminalExecutionId?: string; private readonly _shouldBeBlocked = observableValue(this, false); public get shouldBeBlocked(): IObservable { @@ -439,6 +445,7 @@ export class ChatRequestModel implements IChatRequestModel { this.userSelectedTools = params.userSelectedTools; this.isSystemInitiated = params.isSystemInitiated; this.systemInitiatedLabel = params.systemInitiatedLabel; + this.terminalExecutionId = params.terminalExecutionId; } adoptTo(session: ChatModel) { @@ -1559,6 +1566,7 @@ export interface ISerializableChatRequestData extends ISerializableChatResponseD modeInfo?: IChatRequestModeInfo; isSystemInitiated?: boolean; systemInitiatedLabel?: string; + terminalExecutionId?: string; } export interface ISerializableMarkdownInfo { @@ -2428,6 +2436,7 @@ export class ChatModel extends Disposable implements IChatModel { modeInfo: raw.modeInfo, isSystemInitiated: raw.isSystemInitiated, systemInitiatedLabel: raw.systemInitiatedLabel, + terminalExecutionId: raw.terminalExecutionId, }); request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts @@ -2596,7 +2605,24 @@ export class ChatModel extends Disposable implements IChatModel { this._onDidChange.fire({ kind: 'setHidden' }); } - addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, modeInfo?: IChatRequestModeInfo, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], isCompleteAddedRequest?: boolean, modelId?: string, userSelectedTools?: UserSelectedTools, id?: string, isSystemInitiated?: boolean, systemInitiatedLabel?: string): ChatRequestModel { + addRequest( + message: IParsedChatRequest, + variableData: IChatRequestVariableData, + attempt: number, + modeInfo?: IChatRequestModeInfo, + chatAgent?: IChatAgentData, + slashCommand?: IChatAgentCommand, + confirmation?: string, + locationData?: IChatLocationData, + attachments?: IChatRequestVariableEntry[], + isCompleteAddedRequest?: boolean, + modelId?: string, + userSelectedTools?: UserSelectedTools, + id?: string, + isSystemInitiated?: boolean, + systemInitiatedLabel?: string, + terminalExecutionId?: string + ): ChatRequestModel { const editedFileEvents = [...this.currentEditedFileEvents.values()]; this.currentEditedFileEvents.clear(); const request = new ChatRequestModel({ @@ -2616,6 +2642,7 @@ export class ChatModel extends Disposable implements IChatModel { userSelectedTools, isSystemInitiated, systemInitiatedLabel, + terminalExecutionId, }); request.response = new ChatResponseModel({ responseContent: [], @@ -2775,6 +2802,7 @@ export class ChatModel extends Disposable implements IChatModel { modeInfo: r.modeInfo, isSystemInitiated: r.isSystemInitiated || undefined, systemInitiatedLabel: r.systemInitiatedLabel, + terminalExecutionId: r.terminalExecutionId, ...r.response?.toJSON(), }; }), @@ -2885,6 +2913,9 @@ export function serializeSendOptions(options: IChatSendRequestOptions): ISeriali agentIdSilent: options.agentIdSilent, slashCommand: options.slashCommand, confirmation: options.confirmation, + isSystemInitiated: options.isSystemInitiated, + systemInitiatedLabel: options.systemInitiatedLabel, + terminalExecutionId: options.terminalExecutionId, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts index 7cc5a5425ffcb..656784ee3bd4c 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -19,6 +19,11 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public draftAnswers: IChatQuestionAnswers | undefined; public draftCurrentIndex: number | undefined; public draftCollapsed: boolean | undefined; + /** + * Set to `true` when the carousel was dismissed because the user typed + * directly in the associated terminal instead of using the carousel UI. + */ + public dismissedByTerminalInput?: boolean; constructor( public questions: IChatQuestion[], @@ -28,6 +33,7 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public isUsed?: boolean, public message?: string | IMarkdownString, public source?: ToolDataSource, + public terminalId?: string, ) { } toJSON(): IChatQuestionCarousel { @@ -40,6 +46,7 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { isUsed: this.isUsed, message: this.message, source: this.source, + terminalId: this.terminalId, }; } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index c1227b9423be2..c4b9fdcd48354 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -153,6 +153,7 @@ const requestSchema = Adapt.object m.modeInfo, objectsEqual), isSystemInitiated: Adapt.v(m => m.isSystemInitiated), systemInitiatedLabel: Adapt.v(m => m.systemInitiatedLabel), + terminalExecutionId: Adapt.v(m => m.terminalExecutionId), }, { sealed: (o) => o.modelState?.value === ResponseModelState.Cancelled || o.modelState?.value === ResponseModelState.Failed || o.modelState?.value === ResponseModelState.Complete, }); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index d6897b61fdba9..1a8749cc1a53e 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -11,7 +11,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../nls.js'; -import { IChatQuestion, IChatQuestionAnswers, IChatQuestionAnswerValue, IChatMultiSelectAnswer, IChatService, IChatSingleSelectAnswer } from '../../chatService/chatService.js'; +import { IChatQuestion, IChatQuestionAnswers, IChatQuestionAnswerValue, IChatMultiSelectAnswer, IChatService, IChatSingleSelectAnswer, IChatToolInvocation } from '../../chatService/chatService.js'; import { ChatQuestionCarouselData } from '../../model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatRequestModel } from '../../model/chatModel.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../constants.js'; @@ -24,6 +24,7 @@ import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { raceCancellation } from '../../../../../../base/common/async.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { TerminalToolId } from '../terminalToolIds.js'; /** * Response returned to the model when the user is not available (autopilot mode). @@ -204,6 +205,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { const reason = request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot ? 'Autopilot mode' : 'Auto-reply enabled'; this.logService.info(`[AskQuestionsTool] ${reason}: auto-responding to questions`); const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); + carousel.terminalId = this.extractTerminalId(request); carousel.data = this.buildAutopilotCarouselAnswers(questions, carousel, idToHeaderMap); carousel.isUsed = true; this.chatService.appendProgress(request, carousel); @@ -211,6 +213,8 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { } const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); + carousel.terminalId = this.extractTerminalId(request); + this.logService.trace(`[AskQuestionsTool] request=${request.id} terminalExecutionId=${request.terminalExecutionId ?? 'undefined'} carousel.terminalId=${carousel.terminalId ?? 'undefined'}`); this.chatService.appendProgress(request, carousel); const answerResult = await raceCancellation(carousel.completion.p, token); @@ -218,6 +222,18 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { throw new CancellationError(); } + // When the user typed directly in the terminal (bypassing the carousel), + // tell the agent to stop asking questions and wait for the command to finish. + if (carousel.dismissedByTerminalInput && carousel.terminalId) { + this.logService.info(`[AskQuestionsTool] Carousel dismissed because user typed directly in terminal ${carousel.terminalId}`); + return { + content: [{ + kind: 'text', + value: `The user is replying to the terminal prompts directly. Do not ask more questions or send input to the terminal. You will be automatically notified when the command in terminal ${carousel.terminalId} completes.` + }] + }; + } + progress.report({ message: localize('askQuestionsTool.progress', 'Analyzing your answers...') }); const converted = this.convertCarouselAnswers(questions, answerResult?.answers, idToHeaderMap); @@ -291,6 +307,50 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return { request, sessionResource: chatSessionResource }; } + /** + * Resolves the terminal execution ID for the request. + * Prefer structured metadata and fall back to legacy message parsing for + * old sessions that may not carry the metadata yet. + * As a final fallback, search completed runInTerminal tool invocations in + * the response for the terminal ID (foreground/timeout path where the + * model calls ask_questions from the same turn as runInTerminal). + */ + private extractTerminalId(request: IChatRequestModel): string | undefined { + if (request.terminalExecutionId) { + return request.terminalExecutionId; + } + + const match = request.message.text.match(/\[Terminal (?\S+) notification:/); + if (match?.groups?.termId) { + return match.groups.termId; + } + + // Search completed runInTerminal tool invocations in the response + // for the terminal execution ID (covers foreground/timeout path). + const response = request.response; + if (response) { + const parts = response.response.value; + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i]; + if (part.kind === 'toolInvocation' && part.toolId === TerminalToolId.RunInTerminal) { + const state = part.state.get(); + if (state.type === IChatToolInvocation.StateKind.Completed && state.contentForModel) { + for (const item of state.contentForModel) { + if (item.kind === 'text') { + const idMatch = item.value.match(/terminal ID ([0-9a-fA-F-]+)/); + if (idMatch) { + return idMatch[1]; + } + } + } + } + } + } + } + + return undefined; + } + private toQuestionCarousel(questions: IQuestion[]): { carousel: ChatQuestionCarouselData; idToHeaderMap: Map } { const idToHeaderMap = new Map(); const mappedQuestions = questions.map(question => this.toChatQuestion(question, idToHeaderMap)); diff --git a/src/vs/workbench/contrib/chat/common/tools/terminalToolIds.ts b/src/vs/workbench/contrib/chat/common/tools/terminalToolIds.ts new file mode 100644 index 0000000000000..29169b3ef6a5e --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/terminalToolIds.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Terminal-related tool IDs shared between chat infrastructure and terminal + * contrib. The canonical enum lives here so that `chat/common` can reference + * the IDs without depending on `terminalContrib/`. + * + * `terminalContrib/chatAgentTools/browser/tools/toolIds.ts` re-exports this + * enum so existing imports in that layer continue to work. + */ +export const enum TerminalToolId { + RunInTerminal = 'run_in_terminal', + SendToTerminal = 'send_to_terminal', + GetTerminalOutput = 'get_terminal_output', + KillTerminal = 'kill_terminal', + TerminalSelection = 'terminal_selection', + TerminalLastCommand = 'terminal_last_command', + ConfirmTerminalCommand = 'vscode_get_terminal_confirmation', + CreateAndRunTask = 'create_and_run_task', + GetTaskOutput = 'get_task_output', + RunTask = 'run_task', +} diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap index 374c04da9975c..c3be6013e604b 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap @@ -76,6 +76,7 @@ editedFileEvents: undefined, modelId: undefined, modeInfo: undefined, + terminalExecutionId: undefined, responseId: undefined, result: { metadata: { metadataKey: "value" } }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap index 3119761173f4d..efc873269307e 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap @@ -76,6 +76,7 @@ editedFileEvents: undefined, modelId: undefined, modeInfo: undefined, + terminalExecutionId: undefined, responseId: undefined, result: { errorDetails: { message: "No activated agent with id \"ChatProviderWithUsedContext\"" } }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap index a3d39df663c7f..c7e7f976174e9 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap @@ -78,6 +78,7 @@ editedFileEvents: undefined, modelId: undefined, modeInfo: undefined, + terminalExecutionId: undefined, responseId: undefined, result: { metadata: { metadataKey: "value" } }, responseMarkdownInfo: undefined, @@ -164,6 +165,7 @@ editedFileEvents: undefined, modelId: undefined, modeInfo: undefined, + terminalExecutionId: undefined, responseId: undefined, result: { }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap index 5fc5d409eb3d4..f1d5d9a85c5db 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap @@ -78,6 +78,7 @@ editedFileEvents: undefined, modelId: undefined, modeInfo: undefined, + terminalExecutionId: undefined, responseId: undefined, result: { errorDetails: { message: "No activated agent with id \"ChatProviderWithUsedContext\"" } }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/terminal/terminalContribChatExports.ts b/src/vs/workbench/contrib/terminal/terminalContribChatExports.ts index 9bdffd0cfeac9..770ad2ed0041b 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribChatExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribChatExports.ts @@ -8,3 +8,4 @@ // difficulties in removing the dependency. These are explicitly defined here to avoid an eslint // line override. export { MENU_CHAT_TERMINAL_TOOL_PROGRESS, TerminalChatContextKeys } from '../terminalContrib/chat/browser/terminalChat.js'; +export { RunInTerminalTool } from '../terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.js'; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index ccd46996690a3..0632680730723 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -56,6 +56,7 @@ import { CommandLineFileWriteAnalyzer } from './commandLineAnalyzer/commandLineF import { CommandLineSandboxAnalyzer } from './commandLineAnalyzer/commandLineSandboxAnalyzer.js'; import { OutputMonitor } from './monitoring/outputMonitor.js'; import { IPollingResult, OutputMonitorState } from './monitoring/types.js'; +import { ChatQuestionCarouselData } from '../../../../chat/common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { chatSessionResourceToId, LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { TerminalToolId } from './toolIds.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -1232,7 +1233,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (isAutoApproved) { resultText += `\nEvaluate the terminal output to determine if the command is waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, determine the best answer for the current prompt from context and immediately call ${TerminalToolId.SendToTerminal} with id "${termId}" to send ONLY that one answer. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time until the command finishes. Do NOT send multiple answers at once. Do NOT ask the user or respond with a text message.`; } else { - resultText += `\nEvaluate the terminal output to determine if the command is waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. Do NOT respond with a text message asking the user — use the tool. Then send ONLY the answer for the current prompt using ${TerminalToolId.SendToTerminal} with id "${termId}", call ${TerminalToolId.GetTerminalOutput} to read the next prompt, and repeat one prompt at a time.`; + resultText += `\nEvaluate the terminal output to determine if the command is waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. If you are certain about the exact sequence of upcoming prompts for this command (e.g. well-known interactive commands like npm init), include all of them in a single askQuestions call. Otherwise, ask only about the currently visible prompt. Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.`; } } else if (pollingResult) { resultText += `\n The command is still running, with output:\n`; @@ -1413,8 +1414,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } }); // Register a listener to notify the agent when commands complete in this - // background terminal, and continue the output monitor for prompt-for-input detection - if (shouldSendBackgroundNotifications) { + // background terminal, and continue the output monitor for prompt-for-input detection. + // Always register when the terminal moved to background due to input-needed or timeout + // (the agent promised "you will be notified when the command completes"), regardless + // of the background notifications setting. + const movedToBackground = !executionOptions.persistentSession; + if (shouldSendBackgroundNotifications || movedToBackground) { this._registerCompletionNotification(toolTerminal.instance, termId, chatSessionResource, command, outputMonitor); } else { outputMonitor?.dispose(); @@ -1476,7 +1481,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (isAutoApproved) { resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. Evaluate the terminal output to determine if the command is actually waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, determine the best answer for the current prompt from context and immediately call ${TerminalToolId.SendToTerminal} with id "${termId}" to send ONLY that one answer. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time until the command finishes. Do NOT send multiple answers at once. Do NOT ask the user or respond with a text message.\n\n`); } else { - resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. Evaluate the terminal output to determine if the command is actually waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. Do NOT respond with a text message asking the user — use the tool. Then send ONLY the answer for the current prompt using ${TerminalToolId.SendToTerminal} with id "${termId}", call ${TerminalToolId.GetTerminalOutput} to read the next prompt, and repeat one prompt at a time.\n\n`); + resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. Evaluate the terminal output to determine if the command is actually waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. If you are certain about the exact sequence of upcoming prompts for this command (e.g. well-known interactive commands like npm init), include all of them in a single askQuestions call. Otherwise, ask only about the currently visible prompt. Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.\n\n`); } } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendBackgroundNotifications @@ -1485,7 +1490,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const isAutoApprovedTimeout = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); const inputAction = isAutoApprovedTimeout ? `If it IS waiting for input, determine the best answer for the current prompt from context and immediately call ${TerminalToolId.SendToTerminal} with id "${termId}" to send ONLY that one answer. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time until the command finishes. Do NOT send multiple answers at once. Do NOT ask the user or respond with a text message.` - : `If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. Do NOT respond with a text message asking the user — use the tool. Then send ONLY the answer for the current prompt using ${TerminalToolId.SendToTerminal} with id "${termId}", call ${TerminalToolId.GetTerminalOutput} to read the next prompt, and repeat one prompt at a time.`; + : `If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. If you are certain about the exact sequence of upcoming prompts for this command (e.g. well-known interactive commands like npm init), include all of them in a single askQuestions call. Otherwise, ask only about the currently visible prompt. Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.`; resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint} Use ${TerminalToolId.GetTerminalOutput} to check output before then, ${TerminalToolId.SendToTerminal} to send further input, or ${TerminalToolId.KillTerminal} to stop it. Do NOT use sleep or manual polling to wait. Evaluate the terminal output to determine if the command is waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. ${inputAction}\n\n`); } const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); @@ -1896,6 +1901,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // The monitor wakes only on new terminal data (not on a fixed interval), so // resource cost is proportional to actual terminal activity. const store = new DisposableStore(); + + // Track whether the user has started replying to terminal prompts directly. + // Once set, all future input-needed notifications are suppressed so the agent + // stops asking questions and lets the user finish interacting with the terminal. + let userIsReplyingDirectly = false; + if (outputMonitor) { let lastInputNeededOutput = ''; let lastInputNeededNotificationTime = 0; @@ -1912,6 +1923,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // When the output monitor detects the terminal is waiting for input, // send a steering message so the agent handles it via send_to_terminal. store.add(outputMonitor.onDidDetectInputNeeded(() => { + if (userIsReplyingDirectly) { + this._logService.debug(`RunInTerminalTool: Suppressing input-needed notification for terminal ${termId} because user is replying directly`); + return; + } + const execution = RunInTerminalTool._activeExecutions.get(termId); if (!execution) { return; @@ -1928,22 +1944,35 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); const inputAction = isAutoApproved ? `Determine the best answer for the current prompt from context and immediately call ${TerminalToolId.SendToTerminal} with id "${termId}" to send ONLY that one answer. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time until the command finishes. Do NOT send multiple answers at once. Do NOT ask the user or respond with a text message.` - : `You MUST call the vscode_askQuestions tool to ask the user what values to provide. Do NOT respond with a text message asking the user — use the tool. Then send ONLY the answer for the current prompt using ${TerminalToolId.SendToTerminal} with id "${termId}", call ${TerminalToolId.GetTerminalOutput} to read the next prompt, and repeat one prompt at a time.`; + : `You MUST call the vscode_askQuestions tool to ask the user what values to provide for all anticipated prompts at once (include upcoming prompts you can predict from the command, not just the currently visible one). Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.`; const message = `[Terminal ${termId} notification: command is waiting for input. ${inputAction}]\nTerminal output:\n${currentOutput}`; this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`); this._chatService.sendRequest(chatSessionResource, message, { + ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, systemInitiatedLabel: localize('backgroundTaskNeedsInput', "Background task `{0}` needs input", commandName), - ...sendOptions, + terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send input-needed notification for terminal ${termId}`, e); }); })); } + // When the user types directly in the terminal, dismiss any pending + // question carousel for this terminal so the tool invocation is + // unblocked and the carousel doesn't linger. Also suppress future + // input-needed notifications since the user is handling prompts. + store.add(terminalInstance.onDidInputData(() => { + if (userIsReplyingDirectly) { + return; + } + userIsReplyingDirectly = true; + this._dismissPendingCarouselsForTerminal(chatSessionResource, termId); + })); + store.add(sessionRef); const disposeNotification = () => this._backgroundNotifications.deleteAndDispose(termId); @@ -1967,10 +1996,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Command completed in background terminal ${termId}, notifying chat session`); this._chatService.sendRequest(chatSessionResource, message, { + ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, systemInitiatedLabel: localize('backgroundTaskCompleted', "Background task `{0}` completed", commandName), - ...sendOptions, + terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send completion notification for terminal ${termId}`, e); }); @@ -1997,6 +2027,39 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._backgroundNotifications.set(termId, store); } + + /** + * Find and dismiss any pending (not yet answered) question carousels that + * are associated with the given terminal. This is called when the user + * types directly into the terminal, bypassing the carousel UI. + */ + private _dismissPendingCarouselsForTerminal(chatSessionResource: URI, termId: string): void { + const model = this._chatService.getSession(chatSessionResource); + if (!model) { + return; + } + + // Walk in reverse — there should be at most one pending carousel per terminal. + const requests = model.getRequests(); + for (let i = requests.length - 1; i >= 0; i--) { + const response = requests[i].response; + if (!response) { + continue; + } + const parts = response.response.value; + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j]; + if (part instanceof ChatQuestionCarouselData && part.terminalId === termId && !part.isUsed) { + this._logService.debug(`RunInTerminalTool: Dismissing pending carousel for terminal ${termId} because user typed directly in terminal`); + part.data = {}; + part.isUsed = true; + part.dismissedByTerminalInput = true; + part.completion.complete({ answers: undefined }); + return; + } + } + } + } // #endregion } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts index c4a7ddf8aed90..2d82a43c624dd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts @@ -5,14 +5,16 @@ import type { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { createCommandUri, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { createCommandUri, isMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { hasKey } from '../../../../../../base/common/types.js'; import { IChatWidgetService } from '../../../../chat/browser/chat.js'; -import { IChatService } from '../../../../chat/common/chatService/chatService.js'; +import { IChatService, IChatMultiSelectAnswer, IChatQuestionAnswerValue, IChatQuestionCarousel, IChatSingleSelectAnswer } from '../../../../chat/common/chatService/chatService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { ITerminalService } from '../../../../terminal/browser/terminal.js'; import { buildCommandDisplayText, normalizeCommandForExecution } from '../runInTerminalHelpers.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; @@ -56,12 +58,24 @@ export interface ISendToTerminalInputParams { } const FocusTerminalByIdCommandId = 'workbench.action.terminal.chat.focusTerminalById'; -CommandsRegistry.registerCommand(FocusTerminalByIdCommandId, (accessor, instanceId: number) => { +CommandsRegistry.registerCommand(FocusTerminalByIdCommandId, async (accessor, instanceId: number) => { const terminalService = accessor.get(ITerminalService); const instance = terminalService.getInstanceFromId(instanceId); if (instance) { terminalService.setActiveInstance(instance); - terminalService.revealActiveTerminal(true); + await terminalService.revealActiveTerminal(); + instance.focus(); + } +}); + +const FocusTerminalByExecutionIdCommandId = 'workbench.action.terminal.chat.focusTerminalByExecutionId'; +CommandsRegistry.registerCommand(FocusTerminalByExecutionIdCommandId, async (accessor, executionId: string) => { + const execution = RunInTerminalTool.getExecution(executionId); + if (execution) { + const terminalService = accessor.get(ITerminalService); + terminalService.setActiveInstance(execution.instance); + await terminalService.revealActiveTerminal(); + execution.instance.focus(); } }); @@ -97,6 +111,10 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { const invocationMessage = new MarkdownString(); const pastTenseMessage = new MarkdownString(); + + // Look for the question that prompted this send_to_terminal call + const questionText = this._getQuestionContextForTerminal(context.chatSessionResource, args); + if (isEmptyInput) { invocationMessage.appendMarkdown(localize('send.progressive.enter', "Pressing `Enter` in terminal")); pastTenseMessage.appendMarkdown(localize('send.past.enter', "Pressed `Enter` in terminal")); @@ -107,6 +125,16 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { pastTenseMessage.appendMarkdown(localize('send.past', "Sent {0} to terminal", safeInlineCode)); } + if (questionText) { + const replyPrefix = ` (${localize('send.replyingTo', "replying to: ")}`; + invocationMessage.appendMarkdown(replyPrefix); + invocationMessage.appendText(questionText); + invocationMessage.appendMarkdown(')'); + pastTenseMessage.appendMarkdown(replyPrefix); + pastTenseMessage.appendText(questionText); + pastTenseMessage.appendMarkdown(')'); + } + // Build the confirmation message with a "Focus Terminal" command link const instanceId = this._getTerminalInstanceId(args); const confirmationMessage = new MarkdownString('', { isTrusted: { enabledCommands: [FocusTerminalByIdCommandId] } }); @@ -182,6 +210,109 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { return undefined; } + /** + * Searches the current session's responses for the most recent question + * carousel associated with the target terminal, then matches the command + * text being sent against the carousel's submitted answers to return the + * specific question that this send_to_terminal call is answering. + * + * When a carousel contains multiple questions, the model calls + * send_to_terminal once per answer. This method correlates each call to + * the right question by matching the sent text against answer values. + */ + private _getQuestionContextForTerminal(chatSessionResource: URI | undefined, args: ISendToTerminalInputParams): string | undefined { + if (!chatSessionResource) { + return undefined; + } + + const model = this._chatService.getSession(chatSessionResource); + if (!model) { + return undefined; + } + + // Resolve the terminal ID that will match the carousel's terminalId + if (!args.id && args.terminalId === undefined) { + return undefined; + } + + const commandText = args.command?.trim(); + + // Walk requests in reverse to find the most recent carousel for this terminal + const requests = model.getRequests(); + for (let i = requests.length - 1; i >= 0; i--) { + const response = requests[i].response; + if (!response) { + continue; + } + const parts = response.response.value; + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j]; + if (part.kind === 'questionCarousel') { + const carousel = part as IChatQuestionCarousel; + if (!carousel.terminalId || carousel.questions.length === 0) { + continue; + } + // Match by execution UUID or by resolving the carousel's UUID to an instance ID + const matchesById = !!args.id && carousel.terminalId === args.id; + const matchesByInstanceId = args.terminalId !== undefined && + RunInTerminalTool.getExecution(carousel.terminalId)?.instance.instanceId === args.terminalId; + if (!matchesById && !matchesByInstanceId) { + continue; + } + + // If there's only one question, return it directly + if (carousel.questions.length === 1) { + return this._getQuestionText(carousel.questions[0]); + } + + // Multiple questions: match the command text against submitted answers + if (carousel.data) { + for (const question of carousel.questions) { + const answer = carousel.data[question.id]; + if (this._answerMatchesCommand(answer, commandText)) { + return this._getQuestionText(question); + } + } + } + + // Fallback: return the first question's text + return this._getQuestionText(carousel.questions[0]); + } + } + } + return undefined; + } + + private _getQuestionText(question: IChatQuestionCarousel['questions'][0]): string { + const text = question.message ?? question.title; + return isMarkdownString(text) ? text.value : text; + } + + /** + * Checks whether a carousel answer value matches the command text being sent. + */ + private _answerMatchesCommand(answer: IChatQuestionAnswerValue | undefined, commandText: string): boolean { + if (answer === undefined) { + return false; + } + if (typeof answer === 'string') { + return answer.trim() === commandText; + } + // answer is now IChatSingleSelectAnswer | IChatMultiSelectAnswer + if (hasKey(answer, { selectedValues: true })) { + const multi = answer as IChatMultiSelectAnswer; + if (multi.selectedValues.some(v => v.trim() === commandText)) { + return true; + } + return multi.freeformValue?.trim() === commandText; + } + if (hasKey(answer, { selectedValue: true })) { + const single = answer as IChatSingleSelectAnswer; + return single.selectedValue?.trim() === commandText || single.freeformValue?.trim() === commandText; + } + return false; + } + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { const args = invocation.parameters as ISendToTerminalInputParams; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts index dd8ede93b2162..1e925faa82cd4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts @@ -3,15 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export const enum TerminalToolId { - RunInTerminal = 'run_in_terminal', - SendToTerminal = 'send_to_terminal', - GetTerminalOutput = 'get_terminal_output', - KillTerminal = 'kill_terminal', - TerminalSelection = 'terminal_selection', - TerminalLastCommand = 'terminal_last_command', - ConfirmTerminalCommand = 'vscode_get_terminal_confirmation', - CreateAndRunTask = 'create_and_run_task', - GetTaskOutput = 'get_task_output', - RunTask = 'run_task', -} +// Re-export from the shared location so existing imports in terminalContrib +// continue to work without modification. +export { TerminalToolId } from '../../../../chat/common/tools/terminalToolIds.js'; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 328cd8c399a66..aed868c16bf3d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1745,12 +1745,14 @@ suite('RunInTerminalTool', () => { const commandFinishedEmitter = new Emitter<{ exitCode: number | undefined }>(); const terminalDisposedEmitter = new Emitter(); const inputNeededEmitter = new Emitter(); + const inputDataEmitter = new Emitter(); const terminalInstance = { capabilities: { get: (cap: TerminalCapability) => cap === TerminalCapability.CommandDetection ? { onCommandFinished: commandFinishedEmitter.event } : undefined, }, onDisposed: terminalDisposedEmitter.event, + onDidInputData: inputDataEmitter.event, } as unknown as ITerminalInstance; const outputMonitor = { From ec116f3d8a8614362cc9ec775bf1347bc7c49a9a Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:13:02 -0700 Subject: [PATCH 11/35] Update agent prompts to require intermediary update before analysis channel (#309066) --- .../node/agent/openai/gpt53CodexPrompt.tsx | 2 +- .../node/agent/openai/gpt54ConcisePrompt.tsx | 2 +- .../node/agent/openai/gpt54LargePrompt.tsx | 2 +- .../prompts/node/agent/openai/gpt54Prompt.tsx | 2 +- .../node/agent/openai/hiddenModelBPrompt.tsx | 24 +++++++++---------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt53CodexPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt53CodexPrompt.tsx index 52724ee5c78c7..d1b5878b50edb 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt53CodexPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt53CodexPrompt.tsx @@ -135,7 +135,7 @@ class Gpt53CodexPrompt extends PromptElement { - You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- You provide user updates frequently, every 20s.
- - Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
+ - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- When exploring, e.g. searching, reading files you provide user updates as you go, every 20s, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx index 1c8a9a4405fc6..eb2b00d7d6ad7 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54ConcisePrompt.tsx @@ -147,7 +147,7 @@ export class Gpt54ConcisePromptExp extends PromptElement - You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- - Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
+ - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- When working for a while, keep updates informative and varied, but stay concise.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx index 98b959903482e..2ede60faa73f6 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54LargePrompt.tsx @@ -176,7 +176,7 @@ export class Gpt54LargePromptExp extends PromptElement - User updates are short updates while you are working, they are NOT final answers.
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- - Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
+ - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- When working for a while, keep updates informative and varied, but stay concise.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx index e02bd00af4c0d..7512ef5883208 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt54Prompt.tsx @@ -136,7 +136,7 @@ export class Gpt54Prompt extends PromptElement { - User updates are short updates while you are working, they are NOT final answers.
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- - Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
+ - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- When working for a while, keep updates informative and varied, but stay concise.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx index 5158a0861cc8a..130258fb00d4c 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelBPrompt.tsx @@ -50,10 +50,10 @@ class HiddenModelBPrompt extends PromptElement { - Use `apply_patch` for manual code edits. Do not create or edit files with `cat` or other shell write tricks. Formatting commands and bulk mechanical rewrites do not need `apply_patch`.
- Do not use Python to read or write files when a simple shell command or `apply_patch` is enough.
- You may be in a dirty git worktree.
- * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
- * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, you don't revert those changes.
- * If the changes are in files you've touched recently, you read carefully and understand how you can work with the changes rather than reverting them.
- * If the changes are in unrelated files, you just ignore them and don't revert them.
+ * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
+ * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, you don't revert those changes.
+ * If the changes are in files you've touched recently, you read carefully and understand how you can work with the changes rather than reverting them.
+ * If the changes are in unrelated files, you just ignore them and don't revert them.
- While working, you may encounter changes you did not make. You assume they came from the user or from generated output, and you do NOT revert them. If they are unrelated to your task, you ignore them. If they affect your task, you work **with** them instead of undoing them. Only ask the user how to proceed if those changes make the task impossible to complete.
- Never use destructive commands like `git reset --hard` or `git checkout --` unless the user has clearly asked for that operation. If the request is ambiguous, ask for approval first.
- You are clumsy in the git interactive console. Prefer non-interactive git commands whenever you can.
@@ -83,13 +83,13 @@ class HiddenModelBPrompt extends PromptElement { - You use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.
- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.
- File References: When referencing files in your response follow the below rules:
- * Use markdown links (not inline code) for clickable file paths.
- * Each reference should have a stand alone path. Even if it's the same file.
- * For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).
- * Optionally include line/column (1-based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
- * Do not use URIs like file://, vscode://, or https://.
- * Do not provide range of lines.
- * Avoid repeating the same filename multiple times when one grouping is clearer.
+ * Use markdown links (not inline code) for clickable file paths.
+ * Each reference should have a stand alone path. Even if it's the same file.
+ * For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).
+ * Optionally include line/column (1-based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
+ * Do not use URIs like file://, vscode://, or https://.
+ * Do not provide range of lines.
+ * Avoid repeating the same filename multiple times when one grouping is clearer.
- Don’t use emojis or em dashes unless explicitly instructed.
@@ -109,7 +109,7 @@ class HiddenModelBPrompt extends PromptElement { - Intermediary updates go to the `commentary` channel.
- User updates are short updates while you are working, they are NOT final answers.
- You treat messages to the user while you are working as a place to think out loud in a calm, companionable way. You casually explain what you are doing and why in one or two sentences.
- - Before exploring or doing substantial work, you start with a user update that reflects your understanding of the request and explains your first step. You avoid commenting on the request itself, and you avoid canned starters like "Got it -" or "Understood -".
+ - You must always start with a intermediary update before any content in the `analysis` channel. The initial message should be a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, such as searching or reading files, you provide user updates as you go. You explain what context you are gathering and what you are learning. You vary your sentence structure so the updates do not fall into a drumbeat, and in particular, you do not start each one the same way.
- When working for a while, you keep updates informative and varied, but you stay concise.
From 46be515d069c121b5ed8cd27ad4e7482527cc6fe Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:20:03 -0700 Subject: [PATCH 12/35] fix agent customizations fixture (#309054) --- .../sessions/aiCustomizationManagementEditor.fixture.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index ecbcefbab806c..c2cecd2b3e94b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -23,6 +23,7 @@ import { IMarkdownRendererService } from '../../../../../platform/markdown/brows import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; @@ -500,6 +501,9 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor reg.defineInstance(IFileDialogService, new class extends mock() { }()); reg.defineInstance(IExtensionService, new class extends mock() { }()); reg.defineInstance(IQuickInputService, new class extends mock() { }()); + reg.defineInstance(IViewsService, new class extends mock() { + override async openView(_id: string, _focus?: boolean) { return null as T | null; } + }()); reg.defineInstance(IRequestService, new class extends mock() { }()); reg.defineInstance(IMarkdownRendererService, new class extends mock() { override render() { From f0baeed78e47fe83d69044dac45c24fe19249bed Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:25:16 -0700 Subject: [PATCH 13/35] =?UTF-8?q?Add=20Ctrl+Alt+/=20global=20keyboard=20sh?= =?UTF-8?q?ortcut=20for=20View=20=E2=86=92=20Browser=20(#308950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: add Ctrl+Alt+/ global keyboard shortcut for View -> Browser Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/ed6dcb73-f7ec-4cc2-a952-695c7d0dcf92 Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> --- .../electron-browser/features/browserTabManagementFeatures.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 06ff9eb51206e..2f0c1db0f6c0c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -420,6 +420,10 @@ class OpenBrowserFromViewMenuAction extends Action2 { id: OpenBrowserFromViewMenuAction.ID, title: localize2('browser.openFromViewMenuAction', "Browser"), f1: false, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Slash, + }, }); } From b2f1ae79e73a2a4eb96841da04a2320c2ec53d74 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:28:26 -0400 Subject: [PATCH 14/35] sessions: use focus border for chat inputs (#309060) Match the sessions new-chat and active-chat input borders with the core chat widget focus treatment. Also update the sessions docs to describe the focused border behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 2 ++ src/vs/sessions/browser/media/style.css | 4 ++++ src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md | 2 +- src/vs/sessions/contrib/chat/browser/media/chatWidget.css | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 5cc35ce49241b..167aa55dea5a7 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -430,6 +430,7 @@ Parts manage their own border and background styling via the `updateStyles()` me | Panel | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsPanelBackground` / `PANEL_BORDER`; default light themes map this card to `editorBackground` and darken the outline with `editorWidget.border`, while dark and high-contrast themes keep the existing sidebar-style surface; margins create card offset | The sessions workbench also scopes its resize sash styling in `browser/media/style.css`, rounding the sash hover indicator and orthogonal drag handles so the layout chrome matches the card surfaces. +Both sessions chat input surfaces keep the unfocused `editorWidget.border` outline in light themes, but switch to `focusBorder` while focused so the new-chat view and the active chat input match the core workbench chat widget focus treatment. --- @@ -648,6 +649,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-10 | Updated both sessions chat input surfaces so the standalone new-chat input and the active chat widget input switch their border to `focusBorder` while focused, matching the core workbench chat widget focus treatment. | | 2026-04-08 | Darkened the light-theme-only chat, auxiliary bar, and panel card borders with a sessions-specific CSS `border-color` override that uses `editorWidget.border`; dark and high-contrast themes continue using the existing part border tokens. | | 2026-04-08 | Rounded the sessions workbench sash hover indicators and orthogonal drag handles via `browser/media/style.css` so resize handles use rounded corners instead of square edges. | | 2026-04-04 | Inverted the default light-theme surface mapping so the sessions window background uses the off-white workbench/sidebar surface while the chat, changes, and panel cards use the brighter editor background; dark and high-contrast mappings remain unchanged. | diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index a8098d1e09e8f..687dadf0d0e3e 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -138,6 +138,10 @@ border-radius: var(--vscode-cornerRadius-large); border-color: var(--vscode-editorWidget-border, var(--vscode-widget-border)) !important; } + +.agent-sessions-workbench .interactive-session .chat-input-container.focused { + border-color: var(--vscode-focusBorder) !important; +} .agent-sessions-workbench .interactive-session .interactive-input-part { width: 100%; max-width: 950px; diff --git a/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md b/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md index 6b600f9441cf6..25e566858fdbc 100644 --- a/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md +++ b/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md @@ -155,7 +155,7 @@ Renders the welcome view when the chat is empty: - **Option pickers** — extension-contributed option groups (repository, folder, etc.) - **Input slot** — where the chat input is placed when in welcome mode -The welcome part reads from `IAgentChatTargetConfig` and the `IChatSessionsService` for option groups. +The welcome part reads from `IAgentChatTargetConfig` and the `IChatSessionsService` for option groups. When the shared `ChatInputPart` is rendered in this slot, the sessions window preserves the core `.chat-input-container.focused` focus border behavior so the active chat input uses `focusBorder` while focused. ### 3.4 `AgentSessionsChatInputPart` diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index bf8aa9247e90d..32cba87667b30 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -35,7 +35,7 @@ } .sessions-chat-input-area:focus-within { - border-color: var(--vscode-editorWidget-border, var(--vscode-widget-border, var(--vscode-contrastBorder, transparent))); + border-color: var(--vscode-focusBorder); } /* Editor */ From dede8c6f4a14d1315ff33eab8579303fadd27d0d Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:29:37 +0000 Subject: [PATCH 15/35] Background - add repository state to the metadata (#309034) --- .../common/chatSessionMetadataStore.ts | 5 ++ .../common/chatSessionWorktreeService.ts | 5 ++ .../chatSessionWorkspaceFolderServiceImpl.ts | 52 +++++++++++-- .../chatSessionWorktreeServiceImpl.ts | 76 ++++++++++++++----- .../vscode-node/copilotCLIChatSessions.ts | 33 +++++++- .../copilotCLIChatSessionsContribution.ts | 27 ++++++- .../folderRepositoryManagerImpl.ts | 19 ++++- .../chatSessionWorkspaceFolderService.spec.ts | 2 + .../copilotCLIChatSessionParticipant.spec.ts | 26 +++---- .../test/folderRepositoryManager.spec.ts | 24 +++++- .../src/platform/git/common/gitService.ts | 2 + .../git/vscode-node/gitServiceImpl.ts | 2 + .../test/node/simulationWorkspaceServices.ts | 2 + 13 files changed, 226 insertions(+), 49 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts index 85078d65e5a2c..e1ca45a2943c9 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts @@ -18,6 +18,11 @@ export interface RepositoryProperties { readonly repositoryPath: string; readonly branchName?: string; readonly baseBranchName?: string; + readonly upstreamBranchName?: string; + readonly hasGitHubRemote?: boolean; + readonly incomingChanges?: number; + readonly outgoingChanges?: number; + readonly uncommittedChanges?: number; } /** diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts index 56773450be2bc..2b337dc1fb3aa 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts @@ -40,11 +40,16 @@ export interface ChatSessionWorktreePropertiesV2 extends ChatSessionWorktreeBase readonly autoCommit?: boolean; readonly baseBranchName: string; readonly baseBranchProtected?: boolean; + readonly upstreamBranchName?: string; readonly pullRequestUrl?: string; readonly pullRequestState?: string; readonly firstCheckpointRef?: string; readonly baseCheckpointRef?: string; readonly lastCheckpointRef?: string; + readonly hasGitHubRemote?: boolean; + readonly incomingChanges?: number; + readonly outgoingChanges?: number; + readonly uncommittedChanges?: number; } export type ChatSessionWorktreeProperties = ChatSessionWorktreePropertiesV1 | ChatSessionWorktreePropertiesV2; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts index 66d8129fa7a5f..59474dea8fb37 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts @@ -135,14 +135,35 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh return cachedChanges; } - const changes = await this.computeWorkspaceChanges(repositoryProperties, sessionId); - this.workspaceFolderChanges.set(repoKey, changes); - return changes; + const properties = await this.computeWorkspaceChanges(repositoryProperties, sessionId); + this.workspaceFolderChanges.set(repoKey, properties.changes); + + if ( + properties.incomingChanges !== undefined && + properties.outgoingChanges !== undefined && + properties.uncommittedChanges !== undefined + ) { + await this.metadataStore.storeRepositoryProperties(sessionId, { + ...repositoryProperties, + upstreamBranchName: properties.upstreamBranchName, + incomingChanges: properties.incomingChanges, + outgoingChanges: properties.outgoingChanges, + uncommittedChanges: properties.uncommittedChanges + }); + } + + return properties.changes; }); }); } - private async computeWorkspaceChanges(repositoryProperties: RepositoryProperties, sessionId: string): Promise { + private async computeWorkspaceChanges(repositoryProperties: RepositoryProperties, sessionId: string): Promise<{ + readonly changes: ChatSessionWorktreeFile[]; + readonly upstreamBranchName?: string; + readonly incomingChanges?: number; + readonly outgoingChanges?: number; + readonly uncommittedChanges?: number; + }> { const repository = await this.gitService.getRepository(vscode.Uri.file(repositoryProperties.repositoryPath)); if (repository) { const sessionIds = this.sessionsAssociatedWithFolders.get(repository.rootUri) ?? new Set(); @@ -151,7 +172,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh } if (!repository?.changes) { this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository found for session ${sessionId}`); - return []; + return { changes: [] }; } // Check for untracked changes, only if the session branch matches the current branch @@ -191,7 +212,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); } catch (error) { this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`); - return []; + return { changes: [] }; } finally { try { await fs.rm(path.dirname(diffIndexFile), { recursive: true, force: true }); @@ -208,11 +229,11 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); } catch (error) { this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`); - return []; + return { changes: [] }; } } - return diffChanges.map(change => ({ + const changes = diffChanges.map(change => ({ filePath: change.uri.fsPath, originalFilePath: change.status !== 1 /* INDEX_ADDED */ ? change.originalUri?.fsPath @@ -225,6 +246,21 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh deletions: change.deletions } } satisfies ChatSessionWorktreeFile)); + + const repositoryState = { + upstreamBranchName: repository.upstreamRemote && repository.upstreamBranchName + ? `${repository.upstreamRemote}/${repository.upstreamBranchName}` + : undefined, + incomingChanges: repository.headIncomingChanges ?? 0, + outgoingChanges: repository.headOutgoingChanges ?? 0, + uncommittedChanges: + (repository.changes?.mergeChanges.length ?? 0) + + (repository.changes?.indexChanges.length ?? 0) + + (repository.changes?.workingTree.length ?? 0) + + (repository.changes?.untrackedChanges.length ?? 0) + }; + + return { changes, ...repositoryState }; } clearWorkspaceChanges(sessionId: string): string[]; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index c12da9763d6f8..a74c3a0ac0093 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -10,7 +10,7 @@ import { CancellationToken } from 'vscode-languageserver-protocol'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IGitCommitMessageService } from '../../../platform/git/common/gitCommitMessageService'; -import { IGitService, RepoContext } from '../../../platform/git/common/gitService'; +import { getGitHubRepoInfoFromContext, IGitService, RepoContext } from '../../../platform/git/common/gitService'; import { toGitUri } from '../../../platform/git/common/utils'; import { parseGitChangesRaw } from '../../../platform/git/vscode-node/utils'; import { DiffChange } from '../../../platform/git/vscode/git'; @@ -114,12 +114,27 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi baseCommit = refs.length === 1 && refs[0].commit ? refs[0].commit : undefined; } + const gitHubRemote = getGitHubRepoInfoFromContext(activeRepository); + const incomingChanges = activeRepository.headIncomingChanges ?? 0; + const outgoingChanges = activeRepository.headOutgoingChanges ?? 0; + const uncommittedChanges = (activeRepository.changes?.mergeChanges.length ?? 0) + + (activeRepository.changes?.indexChanges.length ?? 0) + + (activeRepository.changes?.workingTree.length ?? 0) + + (activeRepository.changes?.untrackedChanges.length ?? 0); + return { autoCommit, branchName: branch, baseCommit: baseCommit ?? activeRepository.headCommitHash, baseBranchName, baseBranchProtected, + upstreamBranchName: activeRepository.upstreamRemote && activeRepository.upstreamBranchName + ? `${activeRepository.upstreamRemote}/${activeRepository.upstreamBranchName}` + : undefined, + hasGitHubRemote: gitHubRemote !== undefined, + incomingChanges, + outgoingChanges, + uncommittedChanges, repositoryPath: activeRepository.rootUri.fsPath, worktreePath, version: 2 @@ -376,24 +391,26 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi // the changes. For the Sessions app, we do want to provide updated changes // while the session is in progress. if (worktreeProperties.version === 2 && worktreeProperties.autoCommit === true) { - const changes = vscode.workspace.isAgentSessionsWorkspace - ? await this._getWorktreeChanges(sessionId, worktreeProperties) ?? [] - : await this._getWorktreeChangesFromCommits(worktreeProperties) ?? []; + const properties = vscode.workspace.isAgentSessionsWorkspace + ? await this._getWorktreeChanges(sessionId, worktreeProperties) + : await this._getWorktreeChangesFromCommits(worktreeProperties); await this.setWorktreeProperties(sessionId, { - ...worktreeProperties, changes + ...worktreeProperties, ...properties }); - return changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)); + return properties?.changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)) ?? []; } // Use checkpoints to compute the changes - const changes = await this._getWorktreeChanges(sessionId, worktreeProperties) ?? []; - await this.setWorktreeProperties(sessionId, { - ...worktreeProperties, changes - }); + const properties = await this._getWorktreeChanges(sessionId, worktreeProperties); + if (properties) { + await this.setWorktreeProperties(sessionId, { + ...worktreeProperties, ...properties + }); + } - return changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)); + return properties?.changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)) ?? []; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logService.warn(`[ChatSessionWorktreeCheckpointService][getWorktreeChanges] Session ${sessionId}: error computing diff for committed changes, returning empty. Error: ${errorMessage}`); @@ -653,7 +670,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi return changes; } - private async _getWorktreeChangesFromCommits(worktreeProperties: ChatSessionWorktreePropertiesV2): Promise { + private async _getWorktreeChangesFromCommits(worktreeProperties: ChatSessionWorktreePropertiesV2): Promise<{ changes: readonly ChatSessionWorktreeFile[] } | undefined> { // Open the main repository that contains the worktree. We have to open // the repository so that we can run do `git diff` against the repository // to get the committed changes in the worktree branch. @@ -672,7 +689,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi worktreeProperties.branchName); if (!diff) { - return []; + return { changes: [] }; } const changes = diff.map(change => { @@ -698,10 +715,16 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } satisfies ChatSessionWorktreeFile; }); - return changes; + return { changes }; } - private async _getWorktreeChanges(sessionId: string, worktreeProperties: ChatSessionWorktreeProperties): Promise { + private async _getWorktreeChanges(sessionId: string, worktreeProperties: ChatSessionWorktreeProperties): Promise<{ + readonly changes: readonly ChatSessionWorktreeFile[]; + readonly upstreamBranchName?: string; + readonly incomingChanges?: number; + readonly outgoingChanges?: number; + readonly uncommittedChanges?: number; + } | undefined> { if (worktreeProperties.version !== 2) { this.logService.warn(`[ChatSessionWorktreeService][_getWorktreeChanges] Worktree properties for session ${sessionId} is not version 2.`); return undefined; @@ -723,7 +746,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi ...worktreeRepository.changes?.untrackedChanges ?? [], ].some(change => change.status === 7 /* UNTRACKED */); - const changes: DiffChange[] = []; + const diffChanges: DiffChange[] = []; const worktreePath = vscode.Uri.file(worktreeProperties.worktreePath); if (hasUntrackedChanges) { @@ -743,7 +766,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi // Diff the temp index with the base branch const result = await this.gitService.exec(worktreePath, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', '-z', '--merge-base', worktreeProperties.baseBranchName, '--'], { GIT_INDEX_FILE: diffIndexFile }); - changes.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result)); + diffChanges.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result)); } catch (error) { this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while processing worktree changes for session ${sessionId}: ${error}`); return undefined; @@ -758,14 +781,14 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi // Tracked changes try { const result = await this.gitService.exec(worktreePath, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', '--merge-base', worktreeProperties.baseBranchName, '--']); - changes.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result)); + diffChanges.push(...parseGitChangesRaw(worktreeProperties.worktreePath, result)); } catch (error) { this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while processing worktree changes for session ${sessionId}: ${error}`); return undefined; } } - return changes.map(change => ({ + const changes = diffChanges.map(change => ({ filePath: change.uri.fsPath, originalFilePath: change.status !== 1 /* INDEX_ADDED */ ? change.originalUri?.fsPath @@ -778,6 +801,21 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi deletions: change.deletions } } satisfies ChatSessionWorktreeFile)); + + const repositoryState = { + upstreamBranchName: worktreeRepository.upstreamRemote && worktreeRepository.upstreamBranchName + ? `${worktreeRepository.upstreamRemote}/${worktreeRepository.upstreamBranchName}` + : undefined, + incomingChanges: worktreeRepository.headIncomingChanges ?? 0, + outgoingChanges: worktreeRepository.headOutgoingChanges ?? 0, + uncommittedChanges: + (worktreeRepository.changes?.mergeChanges.length ?? 0) + + (worktreeRepository.changes?.indexChanges.length ?? 0) + + (worktreeRepository.changes?.workingTree.length ?? 0) + + (worktreeRepository.changes?.untrackedChanges.length ?? 0) + }; + + return { changes, ...repositoryState }; } private _toChatSessionChangedFile2(sessionId: string, change: ChatSessionWorktreeFile, worktreeProperties: ChatSessionWorktreeProperties): vscode.ChatSessionChangedFile2 { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index f26cf2653b6e8..c806fe553388c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -338,15 +338,22 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const resource = SessionIdForCLI.getResource(session.id); const item = this.controller.createChatSessionItem(resource, session.label); - const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id); + let worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id); const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath) : session.workingDirectory; item.timing = session.timing; item.status = session.status ?? vscode.ChatSessionStatus.Completed; - const [badge, changes, metadata] = await Promise.all([ + + const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory); + + // We need to get an updated version of worktree properties here because when the + // changes are being computed, the worktree properties are also updated with the + // repository state which we are passing along through the metadata + worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id); + + const [badge, metadata] = await Promise.all([ this.buildBadge(worktreeProperties, workingDirectory), - this.buildChanges(session.id, worktreeProperties, workingDirectory), this.buildMetadata(session.id, worktreeProperties, workingDirectory), ]); item.badge = badge; @@ -420,6 +427,9 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements ? worktreeProperties.baseBranchProtected === true : undefined, branchName: worktreeProperties?.branchName, + upstreamBranchName: worktreeProperties.version === 2 + ? worktreeProperties.upstreamBranchName + : undefined, isolationMode: IsolationMode.Worktree, repositoryPath: worktreeProperties?.repositoryPath, worktreePath: worktreeProperties?.worktreePath, @@ -437,6 +447,18 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements : undefined, lastCheckpointRef: worktreeProperties.version === 2 ? worktreeProperties.lastCheckpointRef + : undefined, + hasGitHubRemote: worktreeProperties.version === 2 + ? worktreeProperties.hasGitHubRemote + : undefined, + incomingChanges: worktreeProperties.version === 2 + ? worktreeProperties.incomingChanges + : undefined, + outgoingChanges: worktreeProperties.version === 2 + ? worktreeProperties.outgoingChanges + : undefined, + uncommittedChanges: worktreeProperties.version === 2 + ? worktreeProperties.uncommittedChanges : undefined } satisfies { readonly [key: string]: unknown }; } @@ -464,7 +486,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements repositoryPath: repositoryProperties?.repositoryPath, branchName: repositoryProperties?.branchName, baseBranchName: repositoryProperties?.baseBranchName, + upstreamBranchName: repositoryProperties?.upstreamBranchName, workingDirectoryPath: workingDirectory?.fsPath, + hasGitHubRemote: repositoryProperties?.hasGitHubRemote, + incomingChanges: repositoryProperties?.incomingChanges, + outgoingChanges: repositoryProperties?.outgoingChanges, + uncommittedChanges: repositoryProperties?.uncommittedChanges, firstCheckpointRef, lastCheckpointRef } satisfies { readonly [key: string]: unknown }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 35cfc3b6c970c..bc196c59edb37 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -238,7 +238,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc public async toChatSessionItem(session: ICopilotCLISessionItem): Promise { const resource = this.sdkToUntitledUriMapping.get(session.id) ?? SessionIdForCLI.getResource(this.untitledSessionIdMapping.get(session.id) ?? session.id); - const worktreeProperties = await this.worktreeManager.getWorktreeProperties(session.id); + let worktreeProperties = await this.worktreeManager.getWorktreeProperties(session.id); const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath) : session.workingDirectory; @@ -291,6 +291,11 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc // Metadata let metadata: { readonly [key: string]: unknown }; + // We need to get an updated version of worktree properties here because when the + // changes are being computed, the worktree properties are also updated with the + // repository state which we are passing along through the metadata + worktreeProperties = await this.worktreeManager.getWorktreeProperties(session.id); + if (worktreeProperties) { // Worktree metadata = { @@ -303,6 +308,9 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc ? worktreeProperties.baseBranchProtected === true : undefined, branchName: worktreeProperties?.branchName, + upstreamBranchName: worktreeProperties.version === 2 + ? worktreeProperties.upstreamBranchName + : undefined, isolationMode: IsolationMode.Worktree, repositoryPath: worktreeProperties?.repositoryPath, worktreePath: worktreeProperties?.worktreePath, @@ -320,6 +328,18 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc : undefined, lastCheckpointRef: worktreeProperties.version === 2 ? worktreeProperties.lastCheckpointRef + : undefined, + hasGitHubRemote: worktreeProperties.version === 2 + ? worktreeProperties.hasGitHubRemote + : undefined, + incomingChanges: worktreeProperties.version === 2 + ? worktreeProperties.incomingChanges + : undefined, + outgoingChanges: worktreeProperties.version === 2 + ? worktreeProperties.outgoingChanges + : undefined, + uncommittedChanges: worktreeProperties.version === 2 + ? worktreeProperties.uncommittedChanges : undefined } satisfies { readonly [key: string]: unknown }; } else { @@ -345,7 +365,12 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc repositoryPath: repositoryProperties?.repositoryPath, branchName: repositoryProperties?.branchName, baseBranchName: repositoryProperties?.baseBranchName, + upstreamBranchName: repositoryProperties?.upstreamBranchName, workingDirectoryPath: workingDirectory?.fsPath, + hasGitHubRemote: repositoryProperties?.hasGitHubRemote, + incomingChanges: repositoryProperties?.incomingChanges, + outgoingChanges: repositoryProperties?.outgoingChanges, + uncommittedChanges: repositoryProperties?.uncommittedChanges, firstCheckpointRef, lastCheckpointRef } satisfies { readonly [key: string]: unknown }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index f63ac1bb7ec5e..460970dfbcee2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -7,7 +7,7 @@ import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { LanguageModelTextPart } from 'vscode'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; -import { IGitService } from '../../../platform/git/common/gitService'; +import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService'; import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { raceCancellation } from '../../../util/vs/base/common/async'; @@ -172,6 +172,16 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol ? await this.gitService.getBranchBase(repoContext.rootUri, repoContext.headBranchName) : undefined; + const gitHubRemote = repoContext + ? getGitHubRepoInfoFromContext(repoContext) + : undefined; + const incomingChanges = repoContext?.headIncomingChanges ?? 0; + const outgoingChanges = repoContext?.headOutgoingChanges ?? 0; + const uncommittedChanges = (repoContext?.changes?.mergeChanges.length ?? 0) + + (repoContext?.changes?.indexChanges.length ?? 0) + + (repoContext?.changes?.workingTree.length ?? 0) + + (repoContext?.changes?.untrackedChanges.length ?? 0); + repositoryUri = repoContext?.rootUri; repositoryProperties = repoContext ? { @@ -180,6 +190,13 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol baseBranchName: branchBase && branchBase.remote && branchBase.name ? `${branchBase.remote}/${branchBase.name}` : undefined, + upstreamBranchName: repoContext?.upstreamRemote && repoContext?.upstreamBranchName + ? `${repoContext.upstreamRemote}/${repoContext.upstreamBranchName}` + : undefined, + hasGitHubRemote: gitHubRemote !== undefined, + incomingChanges, + outgoingChanges, + uncommittedChanges } satisfies RepositoryProperties : undefined; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts index d3d5ad5bf9c8e..723b1c132e3ba 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionWorkspaceFolderService.spec.ts @@ -293,6 +293,8 @@ describe('ChatSessionWorkspaceFolderService', () => { kind: 'repository' as const, headBranchName: 'main', headCommitHash: 'abc123', + headIncomingChanges: 0, + headOutgoingChanges: 0, upstreamBranchName: undefined, upstreamRemote: undefined, isRebasing: false, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 1ce2873289526..f63dca1bc0847 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -480,7 +480,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { // Set up untitled session folder folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`)); // Configure git to return repository for the folder - git.setRepo({ rootUri: Uri.file(`${sep}repo`), kind: 'repository' } as unknown as RepoContext); + git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], kind: 'repository' } as unknown as RepoContext); // Configure worktree service to return worktree properties when createWorktree is called (worktree.createWorktree as unknown as ReturnType).mockResolvedValue(worktreeProperties); @@ -1003,8 +1003,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { }); it('prompts for uncommitted changes action for untitled session with uncommitted changes', async () => { - git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository']; - git.setRepo({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext); + git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository']; + git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext); // Set up untitled session folder so getFolderRepository returns repository info folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`)); // User selects Copy Changes @@ -1030,8 +1030,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { }); it('uses request prompt directly when user accepts uncommitted changes confirmation', async () => { - git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository']; - git.setRepo({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext); + git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository']; + git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext); folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`)); tools.nextConfirmationButton = 'Copy Changes'; @@ -1052,8 +1052,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { }); it('uses request prompt for session label when swapping untitled session', async () => { - git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository']; - git.setRepo({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext); + git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository']; + git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext); folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`)); tools.nextConfirmationButton = 'Move Changes'; @@ -1071,8 +1071,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { }); it('passes empty references array to resolvePrompt after confirmation', async () => { - git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository']; - git.setRepo({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext); + git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository']; + git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext); folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`)); tools.nextConfirmationButton = 'Copy Changes'; @@ -1090,8 +1090,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { }); it('returns empty when user cancels untitled session confirmation', async () => { - git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository']; - git.setRepo({ rootUri: Uri.file(`${sep}repo`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext); + git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository']; + git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext); folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`)); // User clicks Cancel tools.nextConfirmationButton = 'Cancel'; @@ -1173,8 +1173,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { }); it('reuses untitled session after confirmation without creating new session', async () => { - git.activeRepository = { get: () => ({ changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository']; - git.setRepo({ rootUri: Uri.file(`${sep}workspace`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext); + git.activeRepository = { get: () => ({ remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository']; + git.setRepo({ rootUri: Uri.file(`${sep}workspace`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext); // Set up untitled session folder so getFolderRepository returns repository info (for uncommitted changes check) folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}workspace`)); // User selects Copy Changes via the tools confirmation diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts index 70efa87602ee2..f9e6de2afe7ec 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts @@ -472,6 +472,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager.setNewSessionFolder(sessionId, folderUri); gitService.setTestRepository(folderUri, { rootUri: folderUri, + remotes: [] as string[], kind: 'repository' } as RepoContext); @@ -500,6 +501,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager.setNewSessionFolder(sessionId, folderUri); gitService.setTestRepository(folderUri, { rootUri: folderUri, + remotes: [] as string[], kind: 'repository' } as RepoContext); @@ -572,6 +574,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestRepository(folderUri, { rootUri: folderUri, kind: 'repository', + remotes: [] as string[], changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); @@ -604,6 +607,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestRepository(folderUri, { rootUri: folderUri, kind: 'repository', + remotes: [] as string[], changes: { indexChanges: [], workingTree: [{ path: 'file.ts' }], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); @@ -621,6 +625,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestRepository(folderUri, { rootUri: folderUri, kind: 'repository', + remotes: [] as string[], changes: { indexChanges: [], workingTree: [], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); @@ -651,6 +656,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestRepository(folderUri, { rootUri: folderUri, kind: 'repository', + remotes: [] as string[], changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); @@ -665,6 +671,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestActiveRepository({ rootUri: vscode.Uri.file('/workspace'), + remotes: [] as string[], kind: 'repository', changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); @@ -712,6 +719,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestActiveRepository({ rootUri: vscode.Uri.file('/workspace'), + remotes: [] as string[], kind: 'repository', changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); @@ -784,6 +792,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestActiveRepository({ rootUri: vscode.Uri.file(worktreeFolderPath), kind: 'repository', + remotes: [] as string[], changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [{ path: 'other.ts' }], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps); @@ -808,6 +817,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { gitService.setTestRepository(vscode.Uri.file(worktreeFolderPath), { rootUri: vscode.Uri.file(worktreeFolderPath), kind: 'repository', + remotes: [] as string[], changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [{ path: 'other.ts' }], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); @@ -829,7 +839,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { // Git service would return a different repo for this folder gitService.setTestRepository(vscode.Uri.file(worktreeFolderPath), { rootUri: vscode.Uri.file(differentRepo), - kind: 'repository' + kind: 'repository', + remotes: [] as string[], } as RepoContext); const sessionId = 'untitled:wt-test-5'; @@ -849,6 +860,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService.trustResponse = false; gitService.setTestActiveRepository({ rootUri: vscode.Uri.file(worktreeFolderPath), + remotes: [] as string[], kind: 'repository' } as RepoContext); worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps); @@ -874,6 +886,7 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService = new MockWorkspaceService([URI.file('/regular-repo')]); gitService.setTestActiveRepository({ rootUri: regularRepo, + remotes: [] as string[], kind: 'repository' } as RepoContext); // NO worktree properties registered — folder is not a tracked worktree @@ -914,7 +927,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { rootUri: folderUri, kind: 'repository', headBranchName: 'main', - headCommitHash: 'abc123' + headCommitHash: 'abc123', + remotes: [] as string[] } as RepoContext); const result = await manager.getRepositoryInfo(folderUri, token); @@ -946,7 +960,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager.setNewSessionFolder(sessionId, folderUri); gitService.setTestRepository(folderUri, { rootUri: folderUri, - kind: 'repository' + kind: 'repository', + remotes: [] as string[] } as RepoContext); (worktreeService.createWorktree as unknown as ReturnType).mockResolvedValue({ @@ -977,7 +992,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager.setNewSessionFolder(sessionId, folderUri); gitService.setTestRepository(folderUri, { rootUri: folderUri, - kind: 'repository' + kind: 'repository', + remotes: [] as string[] } as RepoContext); (worktreeService.createWorktree as unknown as ReturnType).mockResolvedValue({ diff --git a/extensions/copilot/src/platform/git/common/gitService.ts b/extensions/copilot/src/platform/git/common/gitService.ts index cde0b7d822801..97c3026c35453 100644 --- a/extensions/copilot/src/platform/git/common/gitService.ts +++ b/extensions/copilot/src/platform/git/common/gitService.ts @@ -15,6 +15,8 @@ import { Branch, Change, Commit, CommitOptions, CommitShortStat, DiffChange, Log export interface RepoContext { readonly rootUri: URI; readonly kind: RepositoryKind; + readonly headIncomingChanges: number | undefined; + readonly headOutgoingChanges: number | undefined; readonly headBranchName: string | undefined; readonly headCommitHash: string | undefined; readonly upstreamBranchName: string | undefined; diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index 08e194de9afae..cb3d0f34f6eaa 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -544,6 +544,8 @@ export class RepoContextImpl implements RepoContext { public readonly kind = this._repo.kind; public readonly headBranchName = this._repo.state.HEAD?.name; public readonly headCommitHash = this._repo.state.HEAD?.commit; + public readonly headIncomingChanges = this._repo.state.HEAD?.behind; + public readonly headOutgoingChanges = this._repo.state.HEAD?.ahead; public readonly upstreamBranchName = this._repo.state.HEAD?.upstream?.name; public readonly upstreamRemote = this._repo.state.HEAD?.upstream?.remote; public readonly isRebasing = this._repo.state.rebaseCommit !== null; diff --git a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts index 66f54723622bf..736d2eb8cabc2 100644 --- a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts +++ b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts @@ -729,6 +729,8 @@ export class TestingGitService implements IGitService { kind: 'repository', headBranchName: undefined, headCommitHash: undefined, + headIncomingChanges: 0, + headOutgoingChanges: 0, upstreamBranchName: undefined, upstreamRemote: undefined, isRebasing: false, From bcb148a5bb9ef600bea61bb3bfe1be253742e99e Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:31:48 -0700 Subject: [PATCH 16/35] show toolbar when locked to coding agent (#309071) --- src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index ab003f003613e..31d68e8873a70 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2163,7 +2163,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const agent = this.chatAgentService.getAgent(agentId); this._updateAgentCapabilitiesContextKeys(agent); const supportsCheckpoints = this._attachmentCapabilities.supportsCheckpoints ?? false; - this.listWidget?.updateRendererOptions({ restorable: supportsCheckpoints, editable: supportsCheckpoints, noFooter: true, progressMessageAtBottomOfResponse: true }); + this.listWidget?.updateRendererOptions({ restorable: supportsCheckpoints, editable: supportsCheckpoints, noFooter: false, progressMessageAtBottomOfResponse: true }); if (this.visible) { this.listWidget?.rerender(); } @@ -2185,7 +2185,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewModel.resetInputPlaceholder(); } this.inputEditor?.updateOptions({ placeholder: undefined }); - this.listWidget?.updateRendererOptions({ restorable: true, editable: true, noFooter: false, progressMessageAtBottomOfResponse: mode => mode !== ChatModeKind.Ask }); + this.listWidget?.updateRendererOptions({ restorable: true, editable: true, progressMessageAtBottomOfResponse: mode => mode !== ChatModeKind.Ask }); if (this.visible) { this.listWidget?.rerender(); } From f68930954c02285f443ff15da8d709c4da6fde66 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:36:14 -0700 Subject: [PATCH 17/35] sessions: add Cmd+/ / Ctrl+/ keybinding to open context picker (#309070) Adds a keyboard shortcut handler in the new chat view's input editor so that Cmd+/ (macOS) or Ctrl+/ (Windows/Linux) opens the context attachment picker, matching the core workbench's AttachContextAction keybinding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 71184df2e5938..d715764b849b0 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -8,7 +8,7 @@ import './media/chatWelcomePart.css'; import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter } from '../../../../base/common/event.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -357,6 +357,12 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { e.stopPropagation(); this._send(); } + // Cmd+/ / Ctrl+/ — open the context picker (same as the attach button) + if (e.equals(KeyMod.CtrlCmd | KeyCode.Slash)) { + e.preventDefault(); + e.stopPropagation(); + this._contextAttachments.showPicker(this._getContextFolderUri()); + } })); // Update history navigation enablement based on cursor position From d1ed77a9d4a9a5c9ba2d6bceb25228b1b4035973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Fri, 10 Apr 2026 20:43:08 +0200 Subject: [PATCH 18/35] chore: cleanup recovery extension publish pipeline (#309013) * chore: cleanup recovery extension publish pipeline * fix CI --- .../updateCopilotChatRecoveryVersion.ts | 158 ------------------ .../product-copilot-recovery.yml | 14 +- 2 files changed, 4 insertions(+), 168 deletions(-) delete mode 100644 build/azure-pipelines/common/updateCopilotChatRecoveryVersion.ts diff --git a/build/azure-pipelines/common/updateCopilotChatRecoveryVersion.ts b/build/azure-pipelines/common/updateCopilotChatRecoveryVersion.ts deleted file mode 100644 index ea321f8a8ade5..0000000000000 --- a/build/azure-pipelines/common/updateCopilotChatRecoveryVersion.ts +++ /dev/null @@ -1,158 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { retry, handleAll, ExponentialBackoff } from 'cockatiel'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const MARKETPLACE_EXTENSION_NAME = 'GitHub.copilot-chat'; -const MARKETPLACE_API_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'; -const COPILOT_PACKAGE_JSON_PATH = path.resolve(__dirname, '../../../extensions/copilot/package.json'); - -interface IMarketplaceQueryResult { - readonly results: { - readonly extensions: { - readonly versions: { - readonly version: string; - }[]; - }[]; - }[]; -} - -interface ICopilotPackageJson { - version: string; - [key: string]: unknown; -} - -const retryPolicy = retry(handleAll, { - maxAttempts: 5, - backoff: new ExponentialBackoff() -}); - -async function queryMarketplaceVersions(extensionName: string): Promise { - console.log(`Querying Marketplace for versions of ${extensionName}...`); - - const body = JSON.stringify({ - filters: [{ - criteria: [ - { filterType: 7 /* ExtensionName */, value: extensionName }, - ], - }], - flags: 0x1 /* IncludeVersions */ - }); - - const response = await retryPolicy.execute(context => { - if (context.attempt > 0) { - console.log(`Retrying Marketplace query (attempt ${context.attempt + 1})...`); - } - return fetch(MARKETPLACE_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json;api-version=3.0-preview.1', - 'User-Agent': 'VS Code Build', - }, - body - }); - }); - - if (!response.ok) { - throw new Error(`Marketplace query failed: ${response.status} ${response.statusText}`); - } - - const result: IMarketplaceQueryResult = await response.json(); - const extensions = result.results?.[0]?.extensions; - - if (!extensions?.length) { - console.log('No extensions found in Marketplace response'); - return []; - } - - const versions = extensions[0].versions.map(v => v.version); - console.log(`Found ${versions.length} published version(s)`); - return versions; -} - -function getHighestPatch(versions: string[], major: number, minor: number): number { - let highest = 0; - const matching: string[] = []; - - for (const version of versions) { - const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); - if (!match) { - continue; - } - - const [, vMajor, vMinor, vPatch] = match; - if (Number.parseInt(vMajor, 10) === major && Number.parseInt(vMinor, 10) === minor) { - matching.push(version); - highest = Math.max(highest, Number.parseInt(vPatch, 10)); - } - } - - if (matching.length) { - console.log(`Found ${matching.length} version(s) matching ${major}.${minor}.x: ${matching.join(', ')}`); - console.log(`Highest patch: ${highest}`); - } else { - console.log(`No existing versions found for ${major}.${minor}.x`); - } - - return highest; -} - -async function computeVersion(): Promise<{ version: string; major: number; minor: number }> { - const sourceBranch = process.env['BUILD_SOURCEBRANCH'] ?? ''; - console.log(`Source branch: ${sourceBranch || '(not set)'}`); - - const releaseBranchMatch = /^refs\/heads\/release\/(\d+)\.(\d+)$/.exec(sourceBranch); - - let major: number; - let minor: number; - let patch: number; - - if (releaseBranchMatch) { - major = Number.parseInt(releaseBranchMatch[1], 10); - minor = Number.parseInt(releaseBranchMatch[2], 10); - console.log(`On release branch: ${major}.${minor}`); - - const versions = await queryMarketplaceVersions(MARKETPLACE_EXTENSION_NAME); - const highestPatch = getHighestPatch(versions, major, minor); - patch = highestPatch + 1; - console.log(`Next patch version: ${patch}`); - } else { - console.log('Not on a release branch, set to 1.0.0'); - major = 1; - minor = 0; - patch = 0; - } - - const version = `${major}.${minor}.${patch}`; - console.log(`Computed version: ${version}`); - return { version, major, minor }; -} - -async function updatePackageJson(version: string): Promise { - const packageJsonContents = await fs.readFile(COPILOT_PACKAGE_JSON_PATH, 'utf8'); - const packageJson = JSON.parse(packageJsonContents) as ICopilotPackageJson; - - packageJson.version = version; - - await fs.writeFile(COPILOT_PACKAGE_JSON_PATH, `${JSON.stringify(packageJson, null, '\t')}\n`); - console.log(`Updated ${COPILOT_PACKAGE_JSON_PATH}`); - console.log(`- version: ${version}`); -} - -async function main(): Promise { - const { version } = await computeVersion(); - await updatePackageJson(version); -} - -main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index 88a34bb3f9b3a..92078b5154d53 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -20,9 +20,10 @@ parameters: displayName: Publish Stable Recovery Extension type: boolean default: false - - name: confirm - displayName: This will publish a new recovery Copilot Chat version to the VS Marketplace. You need to run it from a 'release/X.Y' branch. The pipeline will figure out the exact version number based on the branch, eg. 'X.Y.1'. Do you want to continue? Type 'yes' to proceed. - type: string + +variables: + - name: VSCODE_QUALITY + value: stable extends: template: azure-pipelines/extension/stable.yml@templates @@ -44,15 +45,8 @@ extends: echo "##vso[task.logissue type=error]publishExtension requires a release/* branch" exit 1 displayName: Validate release branch - - ${{ if ne(parameters.confirm, 'yes') }}: - - script: | - echo "##vso[task.logissue type=error]publishExtension requires confirm to be 'yes'" - exit 1 - displayName: Validate confirm parameter - template: copilot/setup-steps.yml - - script: node build/azure-pipelines/common/updateCopilotChatRecoveryVersion.ts - displayName: Update Copilot Chat recovery version - template: copilot/build-steps.yml uploadSourceMaps: From a37ccb456cbdb9f0e3514ff7ed7b940048ca4843 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:53:31 -0700 Subject: [PATCH 19/35] chore: run npm audit fix (#309045) --- build/npm/gyp/package-lock.json | 6 +++--- extensions/copilot/package-lock.json | 8 ++++---- extensions/copilot/package.json | 2 +- package-lock.json | 24 ++++++++++++------------ 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 74c28a826cc03..e9d3b2b28c30b 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -138,9 +138,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 61f54d30adce4..bf17ba376485f 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.91", + "@anthropic-ai/claude-agent-sdk": "0.2.98", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", "@github/copilot": "^1.0.21", @@ -180,9 +180,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.92", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.92.tgz", - "integrity": "sha512-loYyxVUC5gBwHjGi9Fv0b84mduJTp9Z3Pum+y/7IVQDb4NynKfVQl6l4VeDKZaW+1QTQtd25tY4hwUznD7Krqw==", + "version": "0.2.98", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.98.tgz", + "integrity": "sha512-pWUx+xY21rKy5wvX0eBZja7p8J5ykOYaHsykvdj9nkTbAVXmP1WusI1mP6jbBByJ8uBJeBc4beAPSZIFcdIpTA==", "license": "SEE LICENSE IN README.md", "dependencies": { "@anthropic-ai/sdk": "^0.80.0", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 348df3867c039..5719221eddeac 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6348,7 +6348,7 @@ "zod": "3.25.76" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.91", + "@anthropic-ai/claude-agent-sdk": "0.2.98", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", "@github/copilot": "^1.0.21", diff --git a/package-lock.json b/package-lock.json index c9bd92277cfcd..755b4fe3aef79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1286,9 +1286,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "dev": true, "license": "MIT", "engines": { @@ -5381,9 +5381,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -5581,9 +5581,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz", + "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==", "dev": true, "license": "MIT", "engines": { @@ -11653,9 +11653,9 @@ } }, "node_modules/hono": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", - "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "dev": true, "license": "MIT", "engines": { From 023cc9a41873ede5bbb7201b323d1c6d83308ba2 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:07:54 -0700 Subject: [PATCH 20/35] Add browser tool instructions to agent prompts (#309075) --- .../prompts/node/agent/anthropicPrompts.tsx | 5 ++- .../node/agent/defaultAgentInstructions.tsx | 8 +++-- .../prompts/node/agent/geminiPrompts.tsx | 6 ++-- .../node/agent/openai/defaultOpenAIPrompt.tsx | 3 +- .../prompts/node/agent/openai/gpt52Prompt.tsx | 3 +- .../prompts/node/agent/xAIPrompts.tsx | 5 +-- .../prompts/node/agent/zaiPrompts.tsx | 3 +- .../src/extension/tools/common/toolNames.ts | 36 ++++++++++++++++++- 8 files changed, 58 insertions(+), 11 deletions(-) diff --git a/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx index 0bca4ddbe6b60..1395c1f7b404e 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx @@ -11,7 +11,7 @@ import { CUSTOM_TOOL_SEARCH_NAME, isAnthropicContextEditingEnabled, isAnthropicC import { IToolDeferralService } from '../../../../platform/networking/common/toolDeferralService'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; -import { ToolName } from '../../../tools/common/toolNames'; +import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; import { ResponseTranslationRules } from '../base/responseTranslationRules'; import { Tag } from '../base/tag'; @@ -174,6 +174,7 @@ class DefaultAnthropicAgentPrompt extends PromptElement {tools[ToolName.CoreRunInTerminal] && <>NEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you.
{this.props.codesearchMode && } @@ -306,6 +307,7 @@ class Claude45DefaultPrompt extends PromptElement { {tools[ToolName.CoreRunInTerminal] && <>NEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you.
@@ -484,6 +486,7 @@ class Claude46OptimizedBasePrompt extends PromptElement {tools[ToolName.CoreRunInTerminal] && <>Do not call {ToolName.CoreRunInTerminal} multiple times in parallel. Run one command and wait for output before running the next.
} {tools[ToolName.ExecutionSubagent] && <>Don't call {ToolName.ExecutionSubagent} multiple times in parallel. Instead, invoke one subagent and wait for its response before running the next command.
} When invoking a tool that takes a file path, always use the absolute file path. If the file has a scheme like untitled: or vscode-userdata:, use a URI with the scheme.
+ {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. Only use tools that are currently available.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx b/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx index 0573293421cfb..efb9de2163487 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx @@ -11,7 +11,7 @@ import { IChatEndpoint } from '../../../../platform/networking/common/networking import { IPromptPathRepresentationService } from '../../../../platform/prompts/common/promptPathRepresentationService'; import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; import { LanguageModelToolMCPSource } from '../../../../vscodeTypes'; -import { ToolName } from '../../../tools/common/toolNames'; +import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; import { IToolsService } from '../../../tools/common/toolsService'; import { InstructionMessage } from '../base/instructionMessage'; import { ResponseTranslationRules } from '../base/responseTranslationRules'; @@ -22,6 +22,7 @@ import { MathIntegrationRules } from '../panel/editorIntegrationRules'; // Types and interfaces for reusable components export interface ToolCapabilities extends Partial> { readonly hasSomeEditTool: boolean; + readonly hasAgenticBrowserTools: boolean; } // Utility function to detect available tools @@ -35,7 +36,8 @@ export function detectToolCapabilities(availableTools: readonly LanguageModelToo return { ...toolMap, - hasSomeEditTool: !!(toolMap[ToolName.EditFile] || toolMap[ToolName.ReplaceString] || toolMap[ToolName.ApplyPatch]) + hasSomeEditTool: !!(toolMap[ToolName.EditFile] || toolMap[ToolName.ReplaceString] || toolMap[ToolName.ApplyPatch]), + hasAgenticBrowserTools: agenticBrowserTools.some(tool => toolMap[tool]), }; } @@ -148,6 +150,7 @@ export class DefaultAgentPrompt extends PromptElement { {tools[ToolName.CoreRunInTerminal] && <>NEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you. {this.props.codesearchMode && } @@ -301,6 +304,7 @@ export class AlternateGPTPrompt extends PromptElement { {tools[ToolName.CoreRunInTerminal] && <>NEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you.
{tools[ToolName.FetchWebPage] && <>If the user provides a URL, you MUST use the {ToolName.FetchWebPage} tool to retrieve the content from the web page. After fetching, review the content returned by {ToolName.FetchWebPage}. If you find any additional URL's or links that are relevant, use the {ToolName.FetchWebPage} tool again to retrieve those links. Recursively gather all relevant information by fetching additional links until you have all of the information that you need.}
diff --git a/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx index 3b17fdb1c68d0..35d3eef52224b 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx @@ -8,7 +8,7 @@ import { ConfigKey, IConfigurationService } from '../../../../platform/configura import { isHiddenModelF } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; -import { ToolName } from '../../../tools/common/toolNames'; +import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; import { ResponseTranslationRules } from '../base/responseTranslationRules'; import { Tag } from '../base/tag'; @@ -58,6 +58,7 @@ export class DefaultGeminiAgentPrompt extends PromptElementNEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you. {this.props.codesearchMode && } @@ -155,6 +156,7 @@ export class HiddenModelFGeminiAgentPrompt extends PromptElementNEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. Only use the tools that are currently available to you. {this.props.codesearchMode && } @@ -260,4 +262,4 @@ class GeminiReminderInstructions extends PromptElementNEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you. {this.props.codesearchMode && } diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt52Prompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt52Prompt.tsx index 9e749570c2ce4..f5e9a39bfe66a 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt52Prompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt52Prompt.tsx @@ -6,7 +6,7 @@ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; import { isGpt52Family } from '../../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../../platform/networking/common/networking'; -import { ToolName } from '../../../../tools/common/toolNames'; +import { agenticBrowserTools, ToolName } from '../../../../tools/common/toolNames'; import { GPT5CopilotIdentityRule } from '../../base/copilotIdentity'; import { InstructionMessage } from '../../base/instructionMessage'; import { ResponseTranslationRules } from '../../base/responseTranslationRules'; @@ -165,6 +165,7 @@ class HiddenModelBPrompt extends PromptElement { - You have access to many tools. If a tool exists to perform a specific task, you MUST use that tool instead of running a terminal command to perform that task.
{tools[ToolName.SearchSubagent] && <>- For efficient codebase exploration, prefer {ToolName.SearchSubagent} to search and gather data instead of directly calling {ToolName.FindTextInFiles}, {ToolName.Codebase} or {ToolName.FindFiles}. Use this as a quick injection of context before beginning to solve the problem yourself.
} {tools[ToolName.CoreRunTest] && <>- Use the {ToolName.CoreRunTest} tool to run tests instead of running terminal commands.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>- Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} {tools[ToolName.ExecutionSubagent] && <> diff --git a/extensions/copilot/src/extension/prompts/node/agent/xAIPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/xAIPrompts.tsx index 393356aaed8bc..24b83f835a1da 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/xAIPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/xAIPrompts.tsx @@ -5,7 +5,7 @@ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; -import { ToolName } from '../../../tools/common/toolNames'; +import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; import { ResponseTranslationRules } from '../base/responseTranslationRules'; import { Tag } from '../base/tag'; @@ -57,6 +57,7 @@ class DefaultGrokCodeFastAgentPrompt extends PromptElementNEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} {!tools.hasSomeEditTool && <>You don't currently have any tools available for editing files. If the user asks you to edit a file, you can ask the user to enable editing tools or print a codeblock with the suggested changes.
} {!tools[ToolName.CoreRunInTerminal] && <>You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you.
{this.props.codesearchMode && } @@ -124,4 +125,4 @@ class XAIPromptResolver implements IAgentPrompt { } } -PromptRegistry.registerPrompt(XAIPromptResolver); \ No newline at end of file +PromptRegistry.registerPrompt(XAIPromptResolver); diff --git a/extensions/copilot/src/extension/prompts/node/agent/zaiPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/zaiPrompts.tsx index b125aa7d19ff5..2415ea5ef1552 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/zaiPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/zaiPrompts.tsx @@ -5,7 +5,7 @@ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; -import { ToolName } from '../../../tools/common/toolNames'; +import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; import { ResponseTranslationRules } from '../base/responseTranslationRules'; import { Tag } from '../base/tag'; @@ -93,6 +93,7 @@ class DefaultZaiAgentPrompt extends PromptElement { {tools[ToolName.CoreRunInTerminal] && <>- {ToolName.CoreRunInTerminal}: Run commands SEQUENTIALLY. Wait for output before running next command. NEVER use for file edits unless user explicitly requests it
} {!tools.hasSomeEditTool && <>- NOTE: No file editing tools available. Ask user to enable them or provide codeblocks as fallback
} {!tools[ToolName.CoreRunInTerminal] && <>- NOTE: No terminal tools available. Ask user to enable them or provide commands as fallback
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>- Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} - Tools may be disabled. Use only currently available tools, regardless of what was used earlier in conversation. diff --git a/extensions/copilot/src/extension/tools/common/toolNames.ts b/extensions/copilot/src/extension/tools/common/toolNames.ts index 2f2fb0286282e..95a1782f6c311 100644 --- a/extensions/copilot/src/extension/tools/common/toolNames.ts +++ b/extensions/copilot/src/extension/tools/common/toolNames.ts @@ -71,8 +71,33 @@ export enum ToolName { ToolSearch = 'tool_search', ResolveMemoryFileUri = 'resolve_memory_file_uri', ExecutionSubagent = 'execution_subagent', + CoreOpenBrowserPage = 'open_browser_page', + CoreClickElement = 'click_element', + CoreScreenshotPage = 'screenshot_page', + CoreNavigatePage = 'navigate_page', + CoreReadPage = 'read_page', + CoreHoverElement = 'hover_element', + CoreDragElement = 'drag_element', + CoreTypeInPage = 'type_in_page', + CoreHandleDialog = 'handle_dialog', + CoreRunPlaywrightCode = 'run_playwright_code', } +/** + * Agentic browser tool IDs that are NOT the open_browser_page tool. + */ +export const agenticBrowserTools = [ + ToolName.CoreClickElement, + ToolName.CoreScreenshotPage, + ToolName.CoreNavigatePage, + ToolName.CoreReadPage, + ToolName.CoreHoverElement, + ToolName.CoreDragElement, + ToolName.CoreTypeInPage, + ToolName.CoreHandleDialog, + ToolName.CoreRunPlaywrightCode, +] as const; + export enum ContributedToolName { ApplyPatch = 'copilot_applyPatch', Codebase = 'copilot_searchCodebase', @@ -184,7 +209,6 @@ export const toolCategories: Record = { // never enabled, so it doesn't matter where it's categorized [ToolName.EditFilesPlaceholder]: ToolCategory.Core, - // Jupyter Notebook Tools [ToolName.CreateNewJupyterNotebook]: ToolCategory.JupyterNotebook, [ToolName.EditNotebook]: ToolCategory.JupyterNotebook, @@ -195,6 +219,16 @@ export const toolCategories: Record = { // Web Interaction [ToolName.FetchWebPage]: ToolCategory.WebInteraction, [ToolName.GithubRepo]: ToolCategory.WebInteraction, + [ToolName.CoreOpenBrowserPage]: ToolCategory.WebInteraction, + [ToolName.CoreClickElement]: ToolCategory.WebInteraction, + [ToolName.CoreScreenshotPage]: ToolCategory.WebInteraction, + [ToolName.CoreNavigatePage]: ToolCategory.WebInteraction, + [ToolName.CoreReadPage]: ToolCategory.WebInteraction, + [ToolName.CoreHoverElement]: ToolCategory.WebInteraction, + [ToolName.CoreDragElement]: ToolCategory.WebInteraction, + [ToolName.CoreTypeInPage]: ToolCategory.WebInteraction, + [ToolName.CoreHandleDialog]: ToolCategory.WebInteraction, + [ToolName.CoreRunPlaywrightCode]: ToolCategory.WebInteraction, // VS Code Interaction [ToolName.SearchWorkspaceSymbols]: ToolCategory.VSCodeInteraction, From c58d92dc93fd0d26cbe2da06dd5769dd41338944 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 10 Apr 2026 15:17:47 -0400 Subject: [PATCH 21/35] add a11y hint to navigate to kb editor results (#309074) --- .../browser/accessibilityConfiguration.ts | 2 +- .../preferences/browser/keybindingsEditor.ts | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index ea36118a6580c..6b198c848b138 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -154,7 +154,7 @@ const configuration: IConfigurationNode = { ...baseVerbosityProperty }, [AccessibilityVerbositySettingId.KeybindingsEditor]: { - description: localize('verbosity.keybindingsEditor.description', 'Provide information about how to change a keybinding in the keybindings editor when a row is focused.'), + description: localize('verbosity.keybindingsEditor.description', 'Provide information about how to change a keybinding in the keybindings editor when a row is focused and how to navigate to the results table.'), ...baseVerbosityProperty }, [AccessibilityVerbositySettingId.Notebook]: { diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts index 6fce0410f8468..ea33217f5f2a6 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts @@ -337,6 +337,13 @@ export class KeybindingsEditor extends EditorPane imp this.ariaLabelElement = DOM.append(parent, DOM.$('')); this.ariaLabelElement.setAttribute('id', 'keybindings-editor-aria-label-element'); this.ariaLabelElement.setAttribute('aria-live', 'assertive'); + this.ariaLabelElement.style.position = 'absolute'; + this.ariaLabelElement.style.width = '1px'; + this.ariaLabelElement.style.height = '1px'; + this.ariaLabelElement.style.overflow = 'hidden'; + this.ariaLabelElement.style.clip = 'rect(1px, 1px, 1px, 1px)'; + this.ariaLabelElement.style.clipPath = 'inset(50%)'; + this.ariaLabelElement.style.whiteSpace = 'nowrap'; } private createOverlayContainer(parent: HTMLElement): void { @@ -602,8 +609,9 @@ export class KeybindingsEditor extends EditorPane imp if (this.keybindingsEditorModel) { const filter = this.searchWidget.getValue(); const keybindingsEntries: IKeybindingItemEntry[] = this.keybindingsEditorModel.fetch(filter, this.sortByPrecedenceAction.checked); - this.accessibilityService.alert(localize('foundResults', "{0} results", keybindingsEntries.length)); - this.ariaLabelElement.setAttribute('aria-label', this.getAriaLabel(keybindingsEntries)); + const ariaLabel = this.getAriaLabel(keybindingsEntries); + this.accessibilityService.alert(ariaLabel); + this.ariaLabelElement.textContent = ariaLabel; if (keybindingsEntries.length === 0) { this.latestEmptyFilters.push(filter); @@ -634,11 +642,19 @@ export class KeybindingsEditor extends EditorPane imp } private getAriaLabel(keybindingsEntries: IKeybindingItemEntry[]): string { + let label: string; if (this.sortByPrecedenceAction.checked) { - return localize('show sorted keybindings', "Showing {0} Keybindings in precedence order", keybindingsEntries.length); + label = localize('show sorted keybindings', "Showing {0} Keybindings in precedence order", keybindingsEntries.length); } else { - return localize('show keybindings', "Showing {0} Keybindings in alphabetical order", keybindingsEntries.length); + label = localize('show keybindings', "Showing {0} Keybindings in alphabetical order", keybindingsEntries.length); } + if (this.configurationService.getValue(AccessibilityVerbositySettingId.KeybindingsEditor)) { + const kb = this.keybindingsService.lookupKeybinding('widgetNavigation.focusNext')?.getAriaLabel(); + if (kb) { + label += '. ' + localize('navigateToResults', "Use {0} to navigate to the results table.", kb); + } + } + return label; } private layoutKeybindingsTable(): void { From 87433b23fb953de58857637257e5a9defebafafa Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 10 Apr 2026 15:43:02 -0400 Subject: [PATCH 22/35] address screen reader feedback on Agents App (#309085) address screen reader feedback --- .../browser/aiCustomizationTreeView.contribution.ts | 5 +++-- .../sessions/contrib/changes/browser/changesViewActions.ts | 4 ++-- .../contrib/chat/browser/sessionsChatAccessibilityHelp.ts | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts index f420a3c699168..259787d457a0c 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts @@ -24,6 +24,7 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { SessionsView, SessionsViewId } from '../../sessions/browser/views/sessionsView.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { TerminalContextKeys } from '../../../../workbench/contrib/terminal/common/terminalContextKey.js'; //#region Utilities @@ -298,8 +299,8 @@ registerAction2(class extends Action2 { f1: true, keybinding: { weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyU, - when: IsSessionsWindowContext, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, + when: ContextKeyExpr.and(IsSessionsWindowContext, TerminalContextKeys.focus.negate()), }, }); } diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index 7d088a9038e72..fc326cae4f8a7 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -57,7 +57,7 @@ registerAction2(class FocusChangesViewAction extends Action2 { f1: true, keybinding: { weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyH, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG, when: IsSessionsWindowContext, }, }); @@ -75,7 +75,7 @@ registerAction2(class FocusChangesFileViewAction extends Action2 { constructor() { super({ id: 'workbench.action.agentSessions.focusChangesFileView', - title: localize2('focusChangesFileView', "Focus on File Explorer"), + title: localize2('focusChangesFileView', "Focus Files Explorer View"), category: Categories.View, precondition: IsSessionsWindowContext, f1: true, diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b1176bfa35790..eea3ecd992197 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -23,11 +23,12 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat const viewsService = accessor.get(IViewsService); const content: string[] = []; - content.push(localize('sessionsChat.overview', "You are in the Agents app chat input. Type a message and press Enter to send it.")); + content.push(localize('sessionsChat.overview', "You are in the Agents app. The Agents app is a dedicated workspace for working with AI agents. It provides a chat interface, a changes view for reviewing agent-generated changes, a file explorer, and customization options.")); + content.push(localize('sessionsChat.input', "You are in the chat input. Type a message and press Enter to send it.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); content.push(localize('sessionsChat.changes', "Focus the Changes view{0}.", '')); - content.push(localize('sessionsChat.filesView', "Focus the Changes files view{0}.", '')); + content.push(localize('sessionsChat.filesView', "Focus the Files Explorer view{0}.", '')); content.push(localize('sessionsChat.sessionsView', "Focus the Chat Sessions view{0}.", '')); content.push(localize('sessionsChat.customizations', "Focus the Chat Customizations view{0}.", ``)); From 4c01ed2e76447456e0ce7077779417a4b05db9cc Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Fri, 10 Apr 2026 15:48:27 -0400 Subject: [PATCH 23/35] Stop fetching GHCR pat (#309086) * Stop fetching GHCR pat * MAKE IT RED --- 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 8e13f586993da..32c6c56cc8973 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -59,7 +59,6 @@ async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { if (!process.stdin.isTTY) { // only in automation secrets["GITHUB_OAUTH_TOKEN"] = await fetchSecret(keyVaultClient, "capi-oauth"); secrets["VSCODE_COPILOT_CHAT_TOKEN"] = await fetchSecret(keyVaultClient, "copilot-token"); - secrets["GHCR_PAT"] = await fetchSecret(keyVaultClient, "ghcr-pat"); secrets["BLACKBIRD_EMBEDDINGS_KEY"] = await fetchSecret(keyVaultClient, "vsc-aoai-key"); secrets["BLACKBIRD_REDIS_CACHE_KEY"] = await fetchSecret(keyVaultClient, "blackbird-redis-cache-key"); @@ -91,4 +90,5 @@ async function main() { main().catch(error => { console.error(red(`Error when setting up .env file:\n${error}`)); + process.exit(1); }); From 7938dfe267d23b4decf2972823ba1d880cb672c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Fri, 10 Apr 2026 21:50:15 +0200 Subject: [PATCH 24/35] hygiene: add check for Copilot engines.vscode version consistency (#309087) --- build/gulpfile.hygiene.ts | 8 +++++++- build/hygiene.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/build/gulpfile.hygiene.ts b/build/gulpfile.hygiene.ts index 24595643c86cd..256e74e08cd0e 100644 --- a/build/gulpfile.hygiene.ts +++ b/build/gulpfile.hygiene.ts @@ -7,7 +7,7 @@ import es from 'event-stream'; import path from 'path'; import fs from 'fs'; import * as task from './lib/task.ts'; -import { hygiene } from './hygiene.ts'; +import { checkCopilotEnginesVersion, hygiene } from './hygiene.ts'; const dirName = path.dirname(new URL(import.meta.url).pathname); @@ -41,6 +41,12 @@ const checkPackageJSONTask = task.define('check-package-json', () => { checkPackageJSON.call(this, 'remote/package.json'); checkPackageJSON.call(this, 'remote/web/package.json'); checkPackageJSON.call(this, 'build/package.json'); + + const repoRoot = path.join(dirName, '..'); + const copilotError = checkCopilotEnginesVersion(repoRoot); + if (copilotError) { + this.emit('error', copilotError); + } }) ); }); diff --git a/build/hygiene.ts b/build/hygiene.ts index 6cc893faf31c2..8dd2927cabe70 100644 --- a/build/hygiene.ts +++ b/build/hygiene.ts @@ -27,6 +27,21 @@ interface VinylFileWithLines extends VinylFile { __lines: string[]; } +/** + * Checks that engines.vscode in extensions/copilot/package.json matches ^{version} from the root package.json. + * Returns an error message if mismatched, or undefined if OK. + */ +export function checkCopilotEnginesVersion(repoRoot: string): string | undefined { + const rootPkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const copilotPkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'extensions/copilot/package.json'), 'utf8')); + const expected = `^${rootPkg.version}`; + const actual = copilotPkg?.engines?.vscode; + if (actual !== expected) { + return `engines.vscode in 'extensions/copilot/package.json' must be "${expected}" (the version from the root package.json), but found "${actual ?? ''}"`; + } + return undefined; +} + /** * Main hygiene function that runs checks on files */ @@ -290,6 +305,15 @@ if (import.meta.main) { const some = out.split(/\r?\n/).filter((l) => !!l); if (some.length > 0) { + // Check copilot engines.vscode version if relevant files are staged + if (some.some(f => f === 'package.json' || f.startsWith('extensions/copilot/'))) { + const copilotError = checkCopilotEnginesVersion(process.cwd()); + if (copilotError) { + console.error(copilotError); + process.exit(1); + } + } + // Run copilot pre-commit checks if copilot files are staged if (some.some(f => f.startsWith('extensions/copilot/'))) { console.log('Running copilot pre-commit checks...'); From e2f2e833c01b9e72db40156e4dcf6f3e759192ee Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:58:02 +0000 Subject: [PATCH 25/35] Agents - switch over to using metadata for the changes view (#309091) * Cleanup code related to the repository state * Remove code specific to the Agents app * HasGitRemote should be computed at the end of each turn to account for sessions that were started before the metadata was added --- .../chatSessionWorkspaceFolderServiceImpl.ts | 23 ++-- .../chatSessionWorktreeServiceImpl.ts | 10 +- extensions/git/src/model.ts | 10 -- .../changes/browser/changesViewModel.ts | 130 ++++-------------- 4 files changed, 45 insertions(+), 128 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts index 59474dea8fb37..b397f5812c430 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts @@ -6,7 +6,7 @@ import { promises as fs } from 'fs'; import * as vscode from 'vscode'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; -import { IGitService } from '../../../platform/git/common/gitService'; +import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService'; import { parseGitChangesRaw } from '../../../platform/git/vscode-node/utils'; import { DiffChange } from '../../../platform/git/vscode/git'; import { ILogService } from '../../../platform/log/common/logService'; @@ -136,15 +136,12 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh } const properties = await this.computeWorkspaceChanges(repositoryProperties, sessionId); - this.workspaceFolderChanges.set(repoKey, properties.changes); + this.workspaceFolderChanges.set(repoKey, properties?.changes ?? []); - if ( - properties.incomingChanges !== undefined && - properties.outgoingChanges !== undefined && - properties.uncommittedChanges !== undefined - ) { + if (properties) { await this.metadataStore.storeRepositoryProperties(sessionId, { ...repositoryProperties, + hasGitHubRemote: properties.hasGitHubRemote, upstreamBranchName: properties.upstreamBranchName, incomingChanges: properties.incomingChanges, outgoingChanges: properties.outgoingChanges, @@ -152,18 +149,19 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh }); } - return properties.changes; + return properties?.changes ?? []; }); }); } private async computeWorkspaceChanges(repositoryProperties: RepositoryProperties, sessionId: string): Promise<{ readonly changes: ChatSessionWorktreeFile[]; + readonly hasGitHubRemote?: boolean; readonly upstreamBranchName?: string; readonly incomingChanges?: number; readonly outgoingChanges?: number; readonly uncommittedChanges?: number; - }> { + } | undefined> { const repository = await this.gitService.getRepository(vscode.Uri.file(repositoryProperties.repositoryPath)); if (repository) { const sessionIds = this.sessionsAssociatedWithFolders.get(repository.rootUri) ?? new Set(); @@ -172,7 +170,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh } if (!repository?.changes) { this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository found for session ${sessionId}`); - return { changes: [] }; + return undefined; } // Check for untracked changes, only if the session branch matches the current branch @@ -212,7 +210,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); } catch (error) { this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`); - return { changes: [] }; + return undefined; } finally { try { await fs.rm(path.dirname(diffIndexFile), { recursive: true, force: true }); @@ -229,7 +227,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); } catch (error) { this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`); - return { changes: [] }; + return undefined; } } @@ -248,6 +246,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh } satisfies ChatSessionWorktreeFile)); const repositoryState = { + hasGitHubRemote: getGitHubRepoInfoFromContext(repository) !== undefined, upstreamBranchName: repository.upstreamRemote && repository.upstreamBranchName ? `${repository.upstreamRemote}/${repository.upstreamBranchName}` : undefined, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index a74c3a0ac0093..4ba514975e327 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -395,9 +395,11 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi ? await this._getWorktreeChanges(sessionId, worktreeProperties) : await this._getWorktreeChangesFromCommits(worktreeProperties); - await this.setWorktreeProperties(sessionId, { - ...worktreeProperties, ...properties - }); + if (properties) { + await this.setWorktreeProperties(sessionId, { + ...worktreeProperties, ...properties + }); + } return properties?.changes.map(change => this._toChatSessionChangedFile2(sessionId, change, worktreeProperties)) ?? []; } @@ -720,6 +722,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi private async _getWorktreeChanges(sessionId: string, worktreeProperties: ChatSessionWorktreeProperties): Promise<{ readonly changes: readonly ChatSessionWorktreeFile[]; + readonly hasGitHubRemote?: boolean; readonly upstreamBranchName?: string; readonly incomingChanges?: number; readonly outgoingChanges?: number; @@ -803,6 +806,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } satisfies ChatSessionWorktreeFile)); const repositoryState = { + hasGitHubRemote: getGitHubRepoInfoFromContext(worktreeRepository) !== undefined, upstreamBranchName: worktreeRepository.upstreamRemote && worktreeRepository.upstreamBranchName ? `${worktreeRepository.upstreamRemote}/${worktreeRepository.upstreamBranchName}` : undefined, diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index adfbb9a8697b6..deecc7c28629a 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -691,16 +691,6 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi this.logger.info(`[Model][openRepository] Opened repository (real path): ${repository.rootRealPath ?? repository.root}`); this.logger.info(`[Model][openRepository] Opened repository (kind): ${gitRepository.kind}`); - // For repositories that are opened in the sessions app, we want to wait for - // the initial `git status` to complete before updating the repository cache - // and firing events. - if (workspace.isAgentSessionsWorkspace) { - await repository.status(); - this._repositoryCache.update(repository.remotes, [], repository.root); - - return; - } - // Do not await this, we want SCM // to know about the repo asap repository.status().then(() => { diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index bf0be7c9edd1f..d5d43af69822f 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -13,8 +13,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { GitDiffChange, GitRepositoryState, IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { hasGitHubRemotes } from '../../../../workbench/contrib/git/common/utils.js'; +import { GitDiffChange, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { COPILOT_CLOUD_SESSION_TYPE } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; @@ -59,8 +58,6 @@ export interface ActiveSessionState { export class ChangesViewModel extends Disposable { readonly activeSessionResourceObs: IObservable; readonly activeSessionTypeObs: IObservable; - readonly activeSessionRepositoryObs: IObservableWithChange; - readonly activeSessionRepositoryStateObs: IObservableWithChange; readonly activeSessionChangesObs: IObservable; readonly activeSessionHasGitRepositoryObs: IObservable; readonly activeSessionFirstCheckpointRefObs: IObservable; @@ -136,11 +133,6 @@ export class ChangesViewModel extends Disposable { return metadata?.lastCheckpointRef as string | undefined; }); - // Active session repository - const { repository, repositoryState } = this._getActiveSessionGitRepository(); - this.activeSessionRepositoryObs = repository; - this.activeSessionRepositoryStateObs = repositoryState; - // Active session state const { isLoading, state } = this._getActiveSessionState(); this.activeSessionIsLoadingObs = isLoading; @@ -195,62 +187,6 @@ export class ChangesViewModel extends Disposable { }); } - private _getActiveSessionGitRepository(): { repository: IObservable; repositoryState: IObservable } { - const activeSessionRepositoryPathObs = derived(reader => { - const metadata = this._activeSessionMetadataObs.read(reader); - const repositoryPath = metadata?.repositoryPath as string | undefined; - const worktreePath = metadata?.worktreePath as string | undefined; - - return worktreePath ?? repositoryPath; - }); - - const activeSessionRepositoryPromiseObs = derived(reader => { - const activeSessionResource = this.activeSessionResourceObs.read(reader); - if (!activeSessionResource) { - return constObservable(undefined); - } - - const activeSessionRepositoryPath = activeSessionRepositoryPathObs.read(reader); - const workingDirectory = activeSessionRepositoryPath - ? URI.file(activeSessionRepositoryPath) - : undefined; - - if (!workingDirectory) { - return constObservable(undefined); - } - - return new ObservablePromise(this.gitService.openRepository(workingDirectory)).resolvedValue; - }); - - const activeSessionGitRepositoryObs = derived(reader => { - const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); - if (activeSessionRepositoryPromise === undefined) { - return undefined; - } - - return activeSessionRepositoryPromise.read(reader); - }); - - const activeSessionGitRepositoryStateObs = derived(reader => { - const repository = activeSessionGitRepositoryObs.read(reader); - const repositoryState = repository?.state.read(reader); - - // If the repository has no HEAD, it is likely not fully loaded yet. - // Treat it as undefined to avoid showing incorrect information to - // the user. - if (!repositoryState?.HEAD) { - return undefined; - } - - return repositoryState; - }); - - return { - repository: activeSessionGitRepositoryObs, - repositoryState: activeSessionGitRepositoryStateObs - }; - } - private _getActiveSessionChanges(): IObservable { // Changes const activeSessionChangesObs = derived(reader => { @@ -261,6 +197,14 @@ export class ChangesViewModel extends Disposable { return activeSession.changes.read(reader); }); + const activeSessionRepositoryPathObs = derived(reader => { + const metadata = this._activeSessionMetadataObs.read(reader); + const repositoryPath = metadata?.repositoryPath as string | undefined; + const worktreePath = metadata?.worktreePath as string | undefined; + + return worktreePath ?? repositoryPath; + }); + // All changes this._activeSessionAllChangesPromiseObs = derived(reader => { const sessionType = this.activeSessionTypeObs.read(reader); @@ -281,15 +225,15 @@ export class ChangesViewModel extends Disposable { } // Local session - const repository = this.activeSessionRepositoryObs.read(reader); + const repositoryPath = activeSessionRepositoryPathObs.read(reader); const firstCheckpointRef = this.activeSessionFirstCheckpointRefObs.read(reader); const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(reader); - if (!repository || !firstCheckpointRef || !lastCheckpointRef) { + if (!repositoryPath || !firstCheckpointRef || !lastCheckpointRef) { return constObservable([]); } - const diffPromise = this._getRepositoryChanges(repository, firstCheckpointRef, lastCheckpointRef); + const diffPromise = this._getRepositoryChanges(repositoryPath, firstCheckpointRef, lastCheckpointRef); return new ObservablePromise(diffPromise).resolvedValue; }); @@ -311,14 +255,14 @@ export class ChangesViewModel extends Disposable { } // Local session - const repository = this.activeSessionRepositoryObs.read(reader); + const repositoryPath = activeSessionRepositoryPathObs.read(reader); const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(reader); - if (!repository || !lastCheckpointRef) { + if (!repositoryPath || !lastCheckpointRef) { return constObservable([]); } - const diffPromise = this._getRepositoryChanges(repository, `${lastCheckpointRef}^`, lastCheckpointRef); + const diffPromise = this._getRepositoryChanges(repositoryPath, `${lastCheckpointRef}^`, lastCheckpointRef); return new ObservablePromise(diffPromise).resolvedValue; }); @@ -345,14 +289,6 @@ export class ChangesViewModel extends Disposable { private _getActiveSessionState(): { isLoading: IObservable; state: IObservable } { const isLoadingObs = derived(reader => { - // If there is a git repository, wait for the repository to be opened first, - // as there are many context keys that depend on the repository information. - const sessionType = this.activeSessionTypeObs.read(reader); - const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); - if (hasGitRepository && sessionType !== COPILOT_CLOUD_SESSION_TYPE && this.activeSessionRepositoryStateObs.read(reader) === undefined) { - return true; - } - // Branch changes const versionMode = this.versionModeObs.read(reader); if (versionMode === ChangesVersionMode.BranchChanges) { @@ -376,15 +312,15 @@ export class ChangesViewModel extends Disposable { const activeSessionStateObs = derivedObservableWithCache(this, (reader, lastValue) => { const isLoading = isLoadingObs.read(reader); - const activeSession = this.sessionManagementService.activeSession.read(reader); - const repositoryState = this.activeSessionRepositoryStateObs.read(reader); - if (isLoading && repositoryState === undefined) { + if (isLoading) { return lastValue; } - // Session state const sessionMetadata = this._activeSessionMetadataObs.read(reader); + const activeSession = this.sessionManagementService.activeSession.read(reader); const workspace = activeSession?.workspace.read(reader); + + // Session state const workspaceRepository = workspace?.repositories[0]; const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); const branchName = (sessionMetadata?.branchName ?? sessionMetadata?.branch) as string | undefined; @@ -396,30 +332,17 @@ export class ChangesViewModel extends Disposable { // Pull request state const gitHubInfo = activeSession?.gitHubInfo.read(reader); - const hasGitHubRemote = repositoryState - ? hasGitHubRemotes(repositoryState) - : false; const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; const hasOpenPullRequest = hasPullRequest && (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id); // Repository state - const upstreamBranchName = hasGitRepository && repositoryState?.HEAD?.upstream - ? `${repositoryState.HEAD.upstream.remote}/${repositoryState.HEAD.upstream.name}` - : undefined; - const incomingChanges = hasGitRepository - ? repositoryState?.HEAD?.behind ?? 0 - : undefined; - const outgoingChanges = hasGitRepository - ? repositoryState?.HEAD?.ahead ?? 0 - : undefined; - const uncommittedChanges = hasGitRepository - ? (repositoryState?.mergeChanges.length ?? 0) + - (repositoryState?.indexChanges.length ?? 0) + - (repositoryState?.workingTreeChanges.length ?? 0) + - (repositoryState?.untrackedChanges.length ?? 0) - : undefined; + const hasGitHubRemote = (sessionMetadata?.hasGitHubRemote as boolean | undefined) === true; + const upstreamBranchName = sessionMetadata?.upstreamBranchName as string | undefined; + const incomingChanges = (sessionMetadata?.incomingChanges as number | undefined) ?? 0; + const outgoingChanges = (sessionMetadata?.outgoingChanges as number | undefined) ?? 0; + const uncommittedChanges = (sessionMetadata?.uncommittedChanges as number | undefined) ?? 0; return { isolationMode, @@ -504,8 +427,9 @@ export class ChangesViewModel extends Disposable { }); } - private async _getRepositoryChanges(repository: IGitRepository, firstCheckpointRef: string, lastCheckpointRef: string): Promise { - const changes = await repository.diffBetweenWithStats(firstCheckpointRef, lastCheckpointRef); + private async _getRepositoryChanges(repositoryPath: string, firstCheckpointRef: string, lastCheckpointRef: string): Promise { + const repository = await this.gitService.openRepository(URI.file(repositoryPath)); + const changes = await repository?.diffBetweenWithStats(firstCheckpointRef, lastCheckpointRef) ?? []; return toIChatSessionFileChange2(changes, firstCheckpointRef, lastCheckpointRef); } From f104e8da29a621554d9471b76b39a7c7e376464d Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 10 Apr 2026 15:59:14 -0400 Subject: [PATCH 26/35] use better message when questions tool skipped bc user is replying to input in the terminal (#309092) fix #309090 --- .../widget/chatContentParts/chatQuestionCarouselPart.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index abb5234d7dc4e..28a6aff151815 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -1521,7 +1521,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private renderSkippedMessage(): void { const skippedContainer = dom.$('.chat-question-carousel-summary'); const skippedMessage = dom.$('.chat-question-summary-skipped'); - skippedMessage.textContent = localize('chat.questionCarousel.skipped', 'Skipped'); + const isDismissedByTerminal = this.carousel instanceof ChatQuestionCarouselData && this.carousel.dismissedByTerminalInput; + skippedMessage.textContent = isDismissedByTerminal + ? localize('chat.questionCarousel.deferredToTerminal', "Deferring to user's input in the terminal") + : localize('chat.questionCarousel.skipped', 'Skipped'); skippedContainer.appendChild(skippedMessage); this.domNode.appendChild(skippedContainer); } From 6cf846ce755fa3493087e825d433b43543ca306a Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 10 Apr 2026 13:02:50 -0700 Subject: [PATCH 27/35] Don't remove model ref on disposal (#309093) Too risky, chat model lifecycle is too confusing and there are still some references to this to do things like check the URI after it's disposed --- src/vs/workbench/contrib/chat/common/model/chatModel.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 879991baa7ed6..83168b0a10326 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1430,8 +1430,6 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel override dispose(): void { super.dispose(); - // Break back-reference to ChatModel to prevent retention cycles - this._session = undefined!; this._response.clear(); if (this._codeBlockInfos) { this._codeBlockInfos.length = 0; From 0121b3da2c55f7674b88b9cd8047fa71f4c55851 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:24:17 -0400 Subject: [PATCH 28/35] sessions: ellipsize sidebar session headers (#309069) Keep the Sessions header and workspace section titles visible in narrow sidebars by truncating them with ellipsis instead of hiding or overflowing the labels. This preserves access to the sidebar section actions when repository names are long. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 3 +++ .../contrib/sessions/browser/media/sessionsList.css | 12 +++++++++--- .../sessions/browser/media/sessionsViewPane.css | 6 ++++++ .../contrib/sessions/browser/views/sessionsView.ts | 13 +++---------- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 167aa55dea5a7..6cfa421d87ab0 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -482,6 +482,7 @@ The Sessions view is registered in `contrib/sessions/browser/sessions.contributi - **View**: `SessionsViewId` with `SessionsView` (`contrib/sessions/browser/views/sessionsView.ts`) - **Window visibility**: `WindowVisibility.Sessions` - **Primary action**: The sidebar content starts with a left-aligned secondary "New Session" button rendered as `$(plus) Session`, with an inline shortcut hint that reflects the active `workbench.action.sessions.newChat` keybinding when one is available +- **Header layout**: The sessions list header label remains visible as the sidebar narrows and truncates with ellipsis instead of being hidden outright; the inline find widget still replaces both the label and actions while open --- @@ -649,6 +650,8 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-10 | Updated workspace/repository section headers in the Sessions sidebar to keep their uppercase titles visible via ellipsis truncation so the section toolbar actions remain reachable when names are long. | +| 2026-04-10 | Updated the Sessions view header so the sidebar "Sessions" label stays visible and truncates with ellipsis when space is tight instead of being hidden; documented the find-widget exception in the Sessions view spec. | | 2026-04-10 | Updated both sessions chat input surfaces so the standalone new-chat input and the active chat widget input switch their border to `focusBorder` while focused, matching the core workbench chat widget focus treatment. | | 2026-04-08 | Darkened the light-theme-only chat, auxiliary bar, and panel card borders with a sessions-specific CSS `border-color` override that uses `editorWidget.border`; dark and high-contrast themes continue using the existing part border tokens. | | 2026-04-08 | Rounded the sessions workbench sash hover indicators and orthogonal drag handles via `browser/media/style.css` so resize handles use rounded corners instead of square edges. | diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index fc0db1a679ab1..13e96298502f9 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -315,17 +315,23 @@ padding: 0 10px; .session-section-label { - flex: 0 1 auto; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .session-section-count { + flex-shrink: 0; opacity: 0.7; - margin-left: auto; + margin-left: 4px; margin-right: 4px; } .session-section-toolbar { - margin-left: auto; + flex-shrink: 0; + margin-left: 4px; display: none; } } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index a790b3f428e50..833bb9789629f 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -85,9 +85,14 @@ } .agent-sessions-header-label { + flex: 1; + min-width: 0; font-size: 12px; font-weight: 500; color: var(--vscode-sideBar-foreground, var(--vscode-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .agent-sessions-header-actions { @@ -95,6 +100,7 @@ align-items: center; gap: 4px; margin-left: auto; + flex-shrink: 0; } /* Inline search input — replaces label + actions when active */ diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index 5175b8a3b4b66..9ca5c423394eb 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -56,7 +56,6 @@ export class SessionsView extends ViewPane { private headerRow: HTMLElement | undefined; private headerLabel: HTMLElement | undefined; private headerActions: HTMLElement | undefined; - private headerLabelWidth = 56; private isFindWidgetOpen = false; sessionsControl: SessionsList | undefined; private _customizationsWidget: AICustomizationShortcutsWidget | undefined; @@ -141,7 +140,6 @@ export class SessionsView extends ViewPane { const headerRow = this.headerRow = DOM.append(sessionsContent, $('.agent-sessions-header-row')); const headerLabel = this.headerLabel = DOM.append(headerRow, $('.agent-sessions-header-label')); headerLabel.textContent = localize('sessionsHeader', "Sessions"); - this.headerLabelWidth = Math.ceil(headerLabel.getBoundingClientRect().width) || this.headerLabelWidth; const headerActions = this.headerActions = DOM.append(headerRow, $('.agent-sessions-header-actions')); @@ -456,7 +454,7 @@ export class SessionsView extends ViewPane { protected override layoutBody(height: number, width: number): void { super.layoutBody(height, width); - this.updateHeaderLayout(width); + this.updateHeaderLayout(); if (!this.sessionsControl || !this.sessionsControlContainer) { return; @@ -485,7 +483,7 @@ export class SessionsView extends ViewPane { this.sessionsControl?.openFind(); } - private updateHeaderLayout(width?: number): void { + private updateHeaderLayout(): void { if (!this.headerRow || !this.headerLabel || !this.headerActions) { return; } @@ -496,12 +494,7 @@ export class SessionsView extends ViewPane { return; } - this.headerActions.style.display = ''; - const headerWidth = width ?? this.headerRow.offsetWidth; - const actionsWidth = Math.ceil(this.headerActions.getBoundingClientRect().width) || this.headerActions.offsetWidth; - const shouldHideLabel = headerWidth > 0 && headerWidth < actionsWidth + this.headerLabelWidth + 12; - - this.headerLabel.style.display = shouldHideLabel ? 'none' : ''; + this.headerLabel.style.display = ''; this.headerActions.style.display = ''; } From aeca54ad55c93c7eef8de4b5c3417a8a49bc1044 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 10 Apr 2026 22:06:05 +0200 Subject: [PATCH 29/35] feat(editTelemetry): add experiment mode to configuration for AI statistics --- .../editTelemetry/browser/editTelemetry.contribution.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetry.contribution.ts b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetry.contribution.ts index 170cced598001..72e1d02a418cc 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetry.contribution.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetry.contribution.ts @@ -29,6 +29,9 @@ configurationRegistry.registerConfiguration({ type: 'boolean', default: true, tags: ['experimental'], + experiment: { + mode: 'auto' + } }, [AI_STATS_SETTING_ID]: { markdownDescription: localize('editor.aiStats.enabled', "Controls whether to enable AI statistics in the editor. The gauge shows the average AI rate across 5-minute sessions, where each session's rate is calculated as AI-inserted characters divided by total inserted characters."), From a19305eca835ceb969c2c9fd378030f2e9a8b5c8 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:09:57 -0700 Subject: [PATCH 30/35] Show historical debug sessions in Agent Debug Panel (#309073) * Show historical debug sessions in Agent Debug Logs * Feedback update * add test --- .../vscode-node/chatDebugFileLoggerService.ts | 25 +++++ .../test/chatDebugFileLoggerService.spec.ts | 66 +++++++++++ .../extension/intents/node/toolCallingLoop.ts | 31 ++++-- .../vscode-node/otelChatDebugLogProvider.ts | 67 +++++++++++ .../extension/vscode.proposed.chatDebug.d.ts | 11 ++ .../chat/common/chatDebugFileLoggerService.ts | 7 ++ .../api/browser/mainThreadChatDebug.ts | 7 ++ .../workbench/api/common/extHost.protocol.ts | 1 + .../workbench/api/common/extHostChatDebug.ts | 8 ++ .../browser/chatDebug/chatDebugHomeView.ts | 104 ++++++++++++++---- .../browser/chatDebug/media/chatDebug.css | 6 + .../contrib/chat/common/chatDebugService.ts | 29 +++++ .../chat/common/chatDebugServiceImpl.ts | 77 +++++++++++++ src/vscode-dts/vscode.proposed.chatDebug.d.ts | 11 ++ 14 files changed, 420 insertions(+), 30 deletions(-) diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts index 52c7f5ecc508e..8dceb151d3964 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatDebugFileLoggerService.ts @@ -1305,6 +1305,31 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug } } + async listSessionIds(): Promise { + const dir = this._getDebugLogsDir(); + if (!dir) { + return []; + } + try { + const entries = await this._fileSystemService.readDirectory(dir); + const dirs = entries.filter(([, type]) => type === 2 /* FileType.Directory */); + + // Stat each directory in parallel to sort by most recently modified. + const withMtime = await Promise.all(dirs.map(async ([name]) => { + try { + const stat = await this._fileSystemService.stat(URI.joinPath(dir, name)); + return { name, mtime: stat.mtime }; + } catch { + return { name, mtime: 0 }; + } + })); + withMtime.sort((a, b) => b.mtime - a.mtime); + return withMtime.map(e => e.name); + } catch { + return []; + } + } + private async _cleanupOldLogs(): Promise { const dir = this._getDebugLogsDir(); if (!dir) { diff --git a/extensions/copilot/src/extension/chat/vscode-node/test/chatDebugFileLoggerService.spec.ts b/extensions/copilot/src/extension/chat/vscode-node/test/chatDebugFileLoggerService.spec.ts index cfee5b7a322e9..6ec24ba59bc18 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/test/chatDebugFileLoggerService.spec.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/test/chatDebugFileLoggerService.spec.ts @@ -750,4 +750,70 @@ describe('ChatDebugFileLoggerService', () => { expect(childHooks).toHaveLength(1); expect(childHooks[0].name).toBe('PreToolUse'); }); + + describe('listSessionIds', () => { + it('returns empty when no sessions exist', async () => { + const ids = await service.listSessionIds(); + expect(ids).toHaveLength(0); + }); + + it('lists session directories on disk', async () => { + await service.startSession('session-a'); + otelService.fireSpan(makeToolCallSpan('session-a', 'read_file')); + await service.flush('session-a'); + + await service.startSession('session-b'); + otelService.fireSpan(makeToolCallSpan('session-b', 'edit_file')); + await service.flush('session-b'); + + const ids = await service.listSessionIds(); + expect(ids).toContain('session-a'); + expect(ids).toContain('session-b'); + }); + + it('returns sessions sorted by most recently modified first', async () => { + await service.startSession('older-session'); + otelService.fireSpan(makeToolCallSpan('older-session', 'read_file')); + await service.flush('older-session'); + + // Small delay so mtime differs + await new Promise(resolve => setTimeout(resolve, 50)); + + await service.startSession('newer-session'); + otelService.fireSpan(makeToolCallSpan('newer-session', 'edit_file')); + await service.flush('newer-session'); + + const ids = await service.listSessionIds(); + expect(ids.indexOf('newer-session')).toBeLessThan(ids.indexOf('older-session')); + }); + + it('does not include non-directory entries', async () => { + // Create a session directory + await service.startSession('real-session'); + otelService.fireSpan(makeToolCallSpan('real-session', 'read_file')); + await service.flush('real-session'); + + // Create a stray file in the debug-logs directory + const debugLogsDir = service.debugLogsDir!; + await fs.promises.writeFile(path.join(debugLogsDir.fsPath, 'stray-file.jsonl'), '{}'); + + const ids = await service.listSessionIds(); + expect(ids).toContain('real-session'); + expect(ids).not.toContain('stray-file.jsonl'); + }); + + it('handles stat failures gracefully', async () => { + await service.startSession('good-session'); + otelService.fireSpan(makeToolCallSpan('good-session', 'read_file')); + await service.flush('good-session'); + + // Create an empty directory that can be listed but stat should still work + const debugLogsDir = service.debugLogsDir!; + await fs.promises.mkdir(path.join(debugLogsDir.fsPath, 'empty-dir')); + + const ids = await service.listSessionIds(); + expect(ids).toContain('good-session'); + expect(ids).toContain('empty-dir'); + }); + }); }); diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index e4d74614c89be..efa1699171d20 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -753,17 +753,30 @@ export abstract class ToolCallingLoop accessor.get(IChatDebugFileLoggerService)); - fileLogger.startChildSession( - chatSessionId, parentChatSessionId, childLabel, parentTraceContext?.spanId); - // Also register the invoke_agent span's ID so that hook spans - // (whose parentSpanId is this span) are routed to the child session. - const invokeSpanId = span.getSpanContext()?.spanId; - if (invokeSpanId) { - fileLogger.registerSpanSession(invokeSpanId, chatSessionId); + + // Register this session as a child of its parent so that debug + // log entries are routed to a dedicated child JSONL file. + // parentChatSessionId is only set on subagent requests + // (see CapturingToken setup in defaultIntentRequestHandler). + if (parentChatSessionId) { + const childLabel = debugLogLabel ?? `runSubagent-${agentName}`; + fileLogger.startChildSession( + chatSessionId, parentChatSessionId, childLabel, parentTraceContext?.spanId); + // Also register the invoke_agent span's ID so that hook spans + // (whose parentSpanId is this span) are routed to the child session. + const invokeSpanId = span.getSpanContext()?.spanId; + if (invokeSpanId) { + fileLogger.registerSpanSession(invokeSpanId, chatSessionId); + } + } else { + // For top-level agent invocations (not subagents), start a debug + // file logging session so entries are flushed to JSONL on disk. + // This is idempotent — calling startSession on an already-started + // session just promotes it if needed. + fileLogger.startSession(chatSessionId).catch(() => { /* best effort */ }); } } diff --git a/extensions/copilot/src/extension/trajectory/vscode-node/otelChatDebugLogProvider.ts b/extensions/copilot/src/extension/trajectory/vscode-node/otelChatDebugLogProvider.ts index 9a496afa53a3c..75de4c76b7fc6 100644 --- a/extensions/copilot/src/extension/trajectory/vscode-node/otelChatDebugLogProvider.ts +++ b/extensions/copilot/src/extension/trajectory/vscode-node/otelChatDebugLogProvider.ts @@ -93,6 +93,8 @@ export class OTelChatDebugLogProviderContribution extends Disposable implements this._provideChatDebugLogExport(sessionResource, options, token), resolveChatDebugLogImport: (data, token) => this._resolveChatDebugLogImport(data, token), + provideAvailableDebugSessionResources: (token) => + this._getAvailableDebugSessionResources(token), })); } catch (e) { this._logService.warn(`[OTelDebug] Failed to register debug log provider: ${e}`); @@ -690,6 +692,71 @@ export class OTelChatDebugLogProviderContribution extends Disposable implements return undefined; } } + + private async _getAvailableDebugSessionResources( + token: vscode.CancellationToken, + ): Promise<{ uri: vscode.Uri; title?: string }[]> { + // Sessions are returned sorted by most recent first from listSessionIds(). + // We only read JSONL tails for a limited batch to keep startup fast. + // The home view shows PAGE_SIZE (5) at a time, so reading ~15 covers + // the visible page plus a buffer for filtered-out discovery-only sessions. + const MAX_TAIL_READS = 15; + + try { + const sessionIds = await this._fileLogger.listSessionIds(); + + // Read tails only for the first batch (most recent sessions). + const toRead = sessionIds.slice(0, MAX_TAIL_READS); + const settled = await Promise.allSettled(toRead.map(async (id): Promise<{ uri: vscode.Uri; title?: string } | undefined> => { + if (token.isCancellationRequested) { + return undefined; + } + const encoded = Buffer.from(id).toString('base64url'); + const uri = vscode.Uri.parse(`vscode-chat-session://local/${encoded}`); + + let title: string | undefined; + let hasRealEvents = false; + try { + const entries = await this._fileLogger.readTailEntries(id, 50); + const userMsg = entries.find(e => e.type === 'user_message'); + if (userMsg) { + hasRealEvents = true; + const content = userMsg.attrs.content as string | undefined; + if (content) { + title = content.length > 80 ? content.slice(0, 80) + '\u2026' : content; + } + } + if (!hasRealEvents) { + hasRealEvents = entries.some(e => + e.type === 'tool_call' || e.type === 'llm_request' || + e.type === 'agent_response' || e.type === 'subagent' + ); + } + } catch { + // best effort + } + if (!hasRealEvents) { + return undefined; + } + if (!title) { + const shortId = id.length > 12 ? id.slice(0, 12) + '\u2026' : id; + title = `Session ${shortId}`; + } + return { uri, title }; + })); + + const results: { uri: vscode.Uri; title?: string }[] = []; + for (const entry of settled) { + if (entry.status === 'fulfilled' && entry.value) { + results.push(entry.value); + } + } + return results; + } catch (err) { + this._logService.error(`[OTelDebug] Failed to list available sessions: ${err}`); + return []; + } + } } // ── Helpers ── diff --git a/extensions/copilot/src/extension/vscode.proposed.chatDebug.d.ts b/extensions/copilot/src/extension/vscode.proposed.chatDebug.d.ts index 06729600819ef..0d14ef4504fcb 100644 --- a/extensions/copilot/src/extension/vscode.proposed.chatDebug.d.ts +++ b/extensions/copilot/src/extension/vscode.proposed.chatDebug.d.ts @@ -737,6 +737,17 @@ declare module 'vscode' { data: Uint8Array, token: CancellationToken ): ProviderResult; + + /** + * Return session resource URIs that have debug log data available, + * including historical sessions persisted on disk. + * + * @param token A cancellation token. + * @returns Session URIs with available debug data and optional titles. + */ + provideAvailableDebugSessionResources?( + token: CancellationToken + ): ProviderResult<{ uri: Uri; title?: string }[]>; } export namespace chat { diff --git a/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts b/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts index eee7eb441633d..3f9462a17be6a 100644 --- a/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts +++ b/extensions/copilot/src/platform/chat/common/chatDebugFileLoggerService.ts @@ -139,6 +139,12 @@ export interface IChatDebugFileLoggerService { * Uses a streaming parser to avoid loading the entire file into memory. */ streamEntries(sessionId: string, onEntry: (entry: IDebugLogEntry) => void): Promise; + + /** + * List session IDs that have debug log directories on disk. + * Returns both active and historical sessions found in the debug-logs/ directory. + */ + listSessionIds(): Promise; } /** @@ -191,4 +197,5 @@ export class NullChatDebugFileLoggerService implements IChatDebugFileLoggerServi async readEntries(): Promise { return []; } async readTailEntries(): Promise { return []; } async streamEntries(): Promise { } + async listSessionIds(): Promise { return []; } } diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts index c28ac56be4d90..bce37c8eab41d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatDebug.ts +++ b/src/vs/workbench/api/browser/mainThreadChatDebug.ts @@ -75,6 +75,13 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb return uri; } })); + + // Register a lazy fetcher so historical sessions are loaded from the + // extension only when the debug panel home page first needs them. + this._chatDebugService.registerAvailableSessionsFetcher(async (token) => { + const entries = await this._proxy.$getAvailableDebugSessionResources(handle, token); + return entries.map(e => ({ uri: URI.revive(e.uri), title: e.title })); + }); } $unregisterChatDebugLogProvider(handle: number): void { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 53e9780fa6629..3397d6166d6f4 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1542,6 +1542,7 @@ export interface ExtHostChatDebugShape { $resolveChatDebugLogEvent(handle: number, eventId: string, token: CancellationToken): Promise; $exportChatDebugLog(handle: number, sessionResource: UriComponents, coreEvents: IChatDebugEventDto[], sessionTitle: string | undefined, token: CancellationToken): Promise; $importChatDebugLog(handle: number, data: VSBuffer, token: CancellationToken): Promise<{ uri: UriComponents; sessionTitle?: string } | undefined>; + $getAvailableDebugSessionResources(handle: number, token: CancellationToken): Promise<{ uri: UriComponents; title?: string }[]>; $onCoreDebugEvent(event: IChatDebugEventDto): void; } diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index 8e84cccaf35a8..60d2048decabd 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -422,6 +422,14 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap return { uri: result.uri, sessionTitle: result.sessionTitle }; } + async $getAvailableDebugSessionResources(_handle: number, token: CancellationToken): Promise<{ uri: UriComponents; title?: string }[]> { + if (!this._provider?.provideAvailableDebugSessionResources) { + return []; + } + const result = await this._provider.provideAvailableDebugSessionResources(token); + return result ?? []; + } + override dispose(): void { for (const store of this._activeProgress.values()) { store.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts index db4b1e1fe18f6..0cbe9f275c3df 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -19,10 +19,13 @@ import { IChatService } from '../../common/chatService/chatService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING } from '../../common/promptSyntax/promptTypes.js'; import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { IChatWidgetService } from '../chat.js'; +import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; const $ = DOM.$; +const PAGE_SIZE = 5; + export class ChatDebugHomeView extends Disposable { private readonly _onNavigateToSession = this._register(new Emitter()); @@ -32,11 +35,21 @@ export class ChatDebugHomeView extends Disposable { private readonly scrollContent: HTMLElement; private readonly renderDisposables = this._register(new DisposableStore()); + /** Number of sessions currently visible (grows on "Show More"). */ + private _visibleCount = PAGE_SIZE; + + /** Session resource that the user last navigated to from the home view. */ + private _lastOpenedSessionResource: URI | undefined; + + /** Tracks the number of known sessions so we can detect new ones. */ + private _lastKnownSessionCount = 0; + constructor( parent: HTMLElement, @IChatService private readonly chatService: IChatService, @IChatDebugService private readonly chatDebugService: IChatDebugService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IConfigurationService private readonly configurationService: IConfigurationService, @IPreferencesService private readonly preferencesService: IPreferencesService, ) { @@ -49,6 +62,24 @@ export class ChatDebugHomeView extends Disposable { this.render(); } })); + + // Re-render when a new session appears so it surfaces at the top. + this._register(this.chatDebugService.onDidAddEvent(e => { + const currentCount = this.chatDebugService.getSessionResources().length; + if (currentCount !== this._lastKnownSessionCount) { + this._lastKnownSessionCount = currentCount; + if (this.container.style.display !== 'none') { + this.render(); + } + } + })); + + // Re-render when historical sessions are discovered from disk. + this._register(this.chatDebugService.onDidChangeAvailableSessionResources(() => { + if (this.container.style.display !== 'none') { + this.render(); + } + })); } show(): void { @@ -61,6 +92,22 @@ export class ChatDebugHomeView extends Disposable { } render(): void { + const isFileLoggingEnabled = this.configurationService.getValue(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING); + this._lastKnownSessionCount = this.chatDebugService.getSessionResources().length; + + const sessionResources = isFileLoggingEnabled + ? this._getFilteredSessionResources(this.chatDebugService.getAvailableSessionResources()) + : []; + this._renderWithSessions(sessionResources); + } + + private _getFilteredSessionResources(resources: readonly URI[]): URI[] { + const cliSessionTypes = new Set(['copilotcli', 'claude-code']); + return [...resources] + .filter(r => !cliSessionTypes.has(getChatSessionType(r)) || !isUntitledChatSession(r)); + } + + private _renderWithSessions(sessionResources: URI[]): void { DOM.clearNode(this.scrollContent); this.renderDisposables.clear(); @@ -85,24 +132,19 @@ export class ChatDebugHomeView extends Disposable { const activeWidget = this.chatWidgetService.lastFocusedWidget; const activeSessionResource = activeWidget?.viewModel?.sessionResource; - // List sessions that have debug event data. - // Use the debug service as the source of truth — it includes sessions - // whose chat models may have been archived (e.g. when a new chat was started). - const cliSessionTypes = new Set(['copilotcli', 'claude-code']); - const sessionResources = [...this.chatDebugService.getSessionResources()].reverse() - // Hide untitled bootstrap sessions for CLI session types (e.g. copilotcli, claude-code). - // These are transient sessions created during async session setup that only contain - // a single "Load Hooks" event and would confuse users. - .filter(r => !cliSessionTypes.has(getChatSessionType(r)) || !isUntitledChatSession(r)); - - // Sort: active session first - if (activeSessionResource) { - const activeIndex = sessionResources.findIndex(r => r.toString() === activeSessionResource.toString()); - if (activeIndex > 0) { - sessionResources.splice(activeIndex, 1); - sessionResources.unshift(activeSessionResource); + // Bubble active sessions to top + const bubbleToTop = (resource: URI | undefined) => { + if (!resource) { + return; } - } + const idx = sessionResources.findIndex(r => r.toString() === resource.toString()); + if (idx > 0) { + sessionResources.splice(idx, 1); + sessionResources.unshift(resource); + } + }; + bubbleToTop(this._lastOpenedSessionResource); + bubbleToTop(activeSessionResource); DOM.append(this.scrollContent, $('p.chat-debug-home-subtitle', undefined, sessionResources.length > 0 @@ -111,22 +153,29 @@ export class ChatDebugHomeView extends Disposable { )); if (sessionResources.length > 0) { + const visibleSessions = sessionResources.slice(0, this._visibleCount); + const sessionList = DOM.append(this.scrollContent, $('.chat-debug-home-session-list')); sessionList.setAttribute('role', 'list'); sessionList.setAttribute('aria-label', localize('chatDebug.sessionList', "Chat sessions")); const items: HTMLButtonElement[] = []; - for (const sessionResource of sessionResources) { - const rawTitle = this.chatService.getSessionTitle(sessionResource); + for (const sessionResource of visibleSessions) { + // Resolve title: agent sessions model (same as sidebar) → chat service → historical from JSONL → fallback + const agentSession = this.agentSessionsService.model.getSession(sessionResource); + const rawTitle = agentSession?.label ?? this.chatService.getSessionTitle(sessionResource); const importedTitle = this.chatDebugService.getImportedSessionTitle(sessionResource); + const historicalTitle = this.chatDebugService.getHistoricalSessionTitle(sessionResource); let sessionTitle: string; if (rawTitle && !isUUID(rawTitle)) { sessionTitle = rawTitle; - } else if (LocalChatSessionUri.isLocalSession(sessionResource)) { - sessionTitle = localize('chatDebug.newSession', "New Chat"); + } else if (historicalTitle) { + sessionTitle = historicalTitle; } else if (importedTitle) { sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", importedTitle); + } else if (LocalChatSessionUri.isLocalSession(sessionResource)) { + sessionTitle = localize('chatDebug.newSession', "New Chat"); } else if (getChatSessionType(sessionResource) === 'copilotcli') { const pathId = sessionResource.path.replace(/^\//, '').split('-')[0]; const shortId = pathId || sessionResource.authority || sessionResource.toString(); @@ -161,11 +210,24 @@ export class ChatDebugHomeView extends Disposable { } this.renderDisposables.add(DOM.addDisposableListener(item, DOM.EventType.CLICK, () => { + this._lastOpenedSessionResource = sessionResource; this._onNavigateToSession.fire(sessionResource); })); items.push(item); } + // "Show More" button when there are more sessions to display + if (sessionResources.length > this._visibleCount) { + const remaining = sessionResources.length - this._visibleCount; + const showMoreButton = this.renderDisposables.add(new Button(this.scrollContent, { ...defaultButtonStyles, secondary: true })); + showMoreButton.element.classList.add('chat-debug-home-show-more'); + showMoreButton.label = localize('chatDebug.showMore', "Show More ({0})", remaining); + this.renderDisposables.add(showMoreButton.onDidClick(() => { + this._visibleCount += PAGE_SIZE; + this.render(); + })); + } + // Arrow key navigation between session items this.renderDisposables.add(DOM.addDisposableListener(sessionList, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { if (items.length === 0) { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index 99eb0631eec22..a38b0f0d6664b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -100,6 +100,12 @@ font-size: 11px; font-weight: 500; } +.chat-debug-home-show-more { + margin-top: 8px; + width: auto; + max-width: 400px; + align-self: center; +} @keyframes chat-debug-shimmer { 0% { background-position: 120% 0; } diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index cbbe23d9f3c06..b4bb179c4ccf1 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -236,6 +236,35 @@ export interface IChatDebugService extends IDisposable { */ getImportedSessionTitle(sessionResource: URI): string | undefined; + /** + * Fired when available session resources change (e.g. historical sessions discovered from disk). + */ + readonly onDidChangeAvailableSessionResources: Event; + + /** + * Store session resources that have debug log data available on disk. + * Called by the main thread after the extension reports historical sessions. + */ + addAvailableSessionResources(resources: readonly { uri: URI; title?: string }[]): void; + + /** + * Get all session resources that have debug log data available, + * including historical sessions persisted on disk by the provider. + * Triggers a lazy fetch from the registered fetcher on first call. + */ + getAvailableSessionResources(): readonly URI[]; + + /** + * Register a callback that fetches available session resources from a provider. + * Called lazily when `getAvailableSessionResources()` is first invoked. + */ + registerAvailableSessionsFetcher(fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>): void; + + /** + * Get the stored title for a historical session discovered from disk. + */ + getHistoricalSessionTitle(sessionResource: URI): string | undefined; + } /** diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index ffb839b804682..84f6c3b2f8a38 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -103,6 +103,9 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic private readonly _onDidClearProviderEvents = this._register(new Emitter()); readonly onDidClearProviderEvents: Event = this._onDidClearProviderEvents.event; + private readonly _onDidChangeAvailableSessionResources = this._register(new Emitter()); + readonly onDidChangeAvailableSessionResources: Event = this._onDidChangeAvailableSessionResources.event; + private readonly _providers = new Set(); private readonly _invocationCts = new ResourceMap(); @@ -112,6 +115,13 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic /** Session URIs created via import. */ private readonly _importedSessions = new ResourceMap(); + /** Session URIs reported by providers as available on disk (historical sessions). */ + private readonly _availableSessionResources: URI[] = []; + private readonly _availableSessionResourceSet = new Set(); + + /** Titles for historical sessions discovered from disk. */ + private readonly _historicalSessionTitles = new ResourceMap(); + /** Human-readable titles for imported sessions. */ private readonly _importedSessionTitles = new ResourceMap(); @@ -291,6 +301,9 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic this._seenEventIds.clear(); this._importedSessions.clear(); this._importedSessionTitles.clear(); + this._availableSessionResources.length = 0; + this._availableSessionResourceSet.clear(); + this._historicalSessionTitles.clear(); } /** Remove all ancillary state for an evicted session. */ @@ -444,6 +457,70 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic return this._importedSessionTitles.get(sessionResource); } + addAvailableSessionResources(resources: readonly { uri: URI; title?: string }[]): void { + let added = false; + for (const { uri, title } of resources) { + const key = uri.toString(); + if (!this._availableSessionResourceSet.has(key)) { + this._availableSessionResourceSet.add(key); + this._availableSessionResources.push(uri); + added = true; + } + if (title) { + this._historicalSessionTitles.set(uri, title); + } + } + if (added) { + this._onDidChangeAvailableSessionResources.fire(); + } + } + + /** Lazy fetcher for available sessions from the extension. */ + private _availableSessionsFetcher: ((token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>) | undefined; + private _availableSessionsFetchStarted = false; + private _availableSessionsRequested = false; + + getAvailableSessionResources(): readonly URI[] { + // Trigger lazy fetch when both a fetcher is registered and this getter is called. + this._availableSessionsRequested = true; + this._tryFetchAvailableSessions(); + + const known = new Set(this._sessionOrder.map(u => u.toString())); + const result = [...this._sessionOrder]; + for (const uri of this._availableSessionResources) { + if (!known.has(uri.toString())) { + known.add(uri.toString()); + result.push(uri); + } + } + return result; + } + + registerAvailableSessionsFetcher(fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>): void { + this._availableSessionsFetcher = fetcher; + this._availableSessionsFetchStarted = false; + // If the UI already requested sessions before the fetcher was registered, fetch now. + this._tryFetchAvailableSessions(); + } + + private _tryFetchAvailableSessions(): void { + if (!this._availableSessionsFetcher || !this._availableSessionsRequested || this._availableSessionsFetchStarted) { + return; + } + this._availableSessionsFetchStarted = true; + // Fire-and-forget: don't block the caller. + const fetcher = this._availableSessionsFetcher; + fetcher(CancellationToken.None).then(entries => { + if (entries.length > 0) { + this.addAvailableSessionResources(entries); + } + }).catch(onUnexpectedError); + } + + getHistoricalSessionTitle(sessionResource: URI): string | undefined { + return this._historicalSessionTitles.get(sessionResource); + } + async exportLog(sessionResource: URI): Promise { for (const provider of this._providers) { if (provider.provideChatDebugLogExport) { diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index 06729600819ef..0d14ef4504fcb 100644 --- a/src/vscode-dts/vscode.proposed.chatDebug.d.ts +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -737,6 +737,17 @@ declare module 'vscode' { data: Uint8Array, token: CancellationToken ): ProviderResult; + + /** + * Return session resource URIs that have debug log data available, + * including historical sessions persisted on disk. + * + * @param token A cancellation token. + * @returns Session URIs with available debug data and optional titles. + */ + provideAvailableDebugSessionResources?( + token: CancellationToken + ): ProviderResult<{ uri: Uri; title?: string }[]>; } export namespace chat { From b713893016bbc5e28e37369e37852ec1f4a522ae Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:15:23 -0700 Subject: [PATCH 31/35] carousel improvements: monaco scrollable + expand button (#308909) * carousel improvements * address some comments * address more comments + fix flickering * use monaco scrollable element * address comments --- .../chatConfirmationWidget.ts | 47 ++++- .../chatContentParts/chatDiffBlockPart.ts | 53 +++++- .../widget/chatContentParts/codeBlockPart.ts | 3 + .../media/chatConfirmationWidget.css | 12 +- .../media/chatToolConfirmationCarousel.css | 52 +++++- .../chatToolConfirmationCarouselPart.ts | 164 +++++++++++++++++- .../chat/browser/widget/chatListRenderer.ts | 6 +- .../browser/widget/input/chatInputPart.ts | 22 +++ 8 files changed, 336 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts index 4d79574e1ff01..5eaf2de7198c9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts @@ -6,10 +6,12 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { Button, ButtonWithDropdown, IButton, IButtonOptions } from '../../../../../../base/browser/ui/button/button.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Action, Separator } from '../../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import type { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; @@ -118,6 +120,8 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { } private readonly messageElement: HTMLElement; + private readonly messageScrollable: DomScrollableElement; + private readonly messageContentDisposables = this._register(new MutableDisposable()); constructor( protected readonly context: IChatContentPartRenderContext, @@ -154,6 +158,18 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { )); this.messageElement = elements.message; + const messageParent = this.messageElement.parentElement; + const messageNextSibling = this.messageElement.nextSibling; + this.messageScrollable = this._register(new DomScrollableElement(this.messageElement, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, + consumeMouseWheelIfScrollbarIsNeeded: true, + })); + this.messageScrollable.getDomNode().classList.add('chat-confirmation-widget-message-scrollable'); + messageParent?.insertBefore(this.messageScrollable.getDomNode(), messageNextSibling); + const messageResizeObserver = this._register(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + this._register(messageResizeObserver.observe(this.messageElement)); + this._register(messageResizeObserver.observe(this.messageScrollable.getDomNode())); // Create buttons buttons.forEach(buttonData => { @@ -216,7 +232,12 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { } protected renderMessage(element: HTMLElement): void { + const store = new DisposableStore(); + const messageContentResizeObserver = store.add(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + store.add(messageContentResizeObserver.observe(element)); + this.messageContentDisposables.value = store; this.messageElement.append(element); + this.messageScrollable.scanDomNode(); } } @@ -271,6 +292,8 @@ abstract class BaseChatConfirmationWidget extends Disposable { } private readonly messageElement: HTMLElement; + private readonly messageScrollable: DomScrollableElement; + private readonly messageContentDisposables = this._register(new MutableDisposable()); private readonly markdownContentPart = this._register(new MutableDisposable()); public get codeblocksPartId() { @@ -320,6 +343,18 @@ abstract class BaseChatConfirmationWidget extends Disposable { )); this.messageElement = elements.message; + const messageParent = this.messageElement.parentElement; + const messageNextSibling = this.messageElement.nextSibling; + this.messageScrollable = this._register(new DomScrollableElement(this.messageElement, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, + consumeMouseWheelIfScrollbarIsNeeded: true, + })); + this.messageScrollable.getDomNode().classList.add('chat-confirmation-widget-message-scrollable'); + messageParent?.insertBefore(this.messageScrollable.getDomNode(), messageNextSibling); + const messageResizeObserver = this._register(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + this._register(messageResizeObserver.observe(this.messageElement)); + this._register(messageResizeObserver.observe(this.messageScrollable.getDomNode())); this.updateButtons(buttons); @@ -414,10 +449,16 @@ abstract class BaseChatConfirmationWidget extends Disposable { element = part.domNode; } - for (const child of this.messageElement.children) { - child.remove(); + dom.clearNode(this.messageElement); + const store = new DisposableStore(); + const messageContentResizeObserver = store.add(new dom.DisposableResizeObserver(() => this.messageScrollable.scanDomNode())); + store.add(messageContentResizeObserver.observe(element)); + if (this.markdownContentPart.value) { + store.add(this.markdownContentPart.value.onDidChangeHeight(() => this.messageScrollable.scanDomNode())); } + this.messageContentDisposables.value = store; this.messageElement.append(element); + this.messageScrollable.scanDomNode(); } } export class ChatConfirmationWidget extends BaseChatConfirmationWidget { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDiffBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDiffBlockPart.ts index d5f8da36a3cf7..8d4207696c052 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDiffBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDiffBlockPart.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { hashAsync } from '../../../../../../base/common/hash.js'; -import { Disposable, IReference, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, IReference, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; @@ -122,15 +122,50 @@ export class MarkdownDiffBlockPart extends Disposable { const languageSelection = this.languageService.createById(data.languageId); - // Create the models - this._register(this.modelService.createModel(data.beforeContent, languageSelection, originalUri, false)); - this._register(this.modelService.createModel(data.afterContent, languageSelection, modifiedUri, false)); + const originalModel = this.modelService.createModel(data.beforeContent, languageSelection, originalUri, false); + const modifiedModel = this.modelService.createModel(data.afterContent, languageSelection, modifiedUri, false); + const cts = new CancellationTokenSource(); + let referencesSettled = false; + let disposeRequested = false; + let didDisposeModels = false; + const disposeModels = () => { + if (didDisposeModels) { + return; + } + + didDisposeModels = true; + originalModel.dispose(); + modifiedModel.dispose(); + }; + this._register(toDisposable(() => { + disposeRequested = true; + cts.dispose(true); + if (referencesSettled) { + disposeModels(); + } + })); const modelsPromise = Promise.all([ this.textModelService.createModelReference(originalUri), this.textModelService.createModelReference(modifiedUri) ]).then(([originalRef, modifiedRef]) => { - return new SimpleDiffEditorModel(originalRef, modifiedRef); + referencesSettled = true; + const model = new SimpleDiffEditorModel(originalRef, modifiedRef); + if (disposeRequested) { + model.dispose(); + disposeModels(); + return undefined; + } + + return model; + }, error => { + referencesSettled = true; + disposeModels(); + if (disposeRequested) { + return undefined; + } + + throw error; }); const compareData: ICodeCompareBlockData = { @@ -144,6 +179,10 @@ export class MarkdownDiffBlockPart extends Disposable { done: true }, diffData: modelsPromise.then(async model => { + if (!model) { + return undefined; + } + this.modelRef.value = model; const diffData: ICodeCompareBlockDiffData = { original: model.original, @@ -154,7 +193,7 @@ export class MarkdownDiffBlockPart extends Disposable { }) }; - this.comparePart.object.render(compareData, currentWidth, CancellationToken.None); + this.comparePart.object.render(compareData, currentWidth, cts.token); this.element = this.comparePart.object.element; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index e7b9439112455..0022affa04472 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -922,6 +922,9 @@ export class CodeCompareBlockPart extends Disposable { } const diffData = await data.diffData; + if (token.isCancellationRequested) { + return; + } if (!isEditApplied && diffData) { const viewModel = this.diffEditor.createViewModel({ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css index dbb24187e3fc1..fc348d6f328e8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css @@ -112,9 +112,8 @@ } .chat-confirmation-widget .chat-buttons-container, -.chat-confirmation-widget .chat-confirmation-widget-message { +.chat-confirmation-widget .chat-confirmation-widget-message-scrollable { flex-basis: 100%; - padding: 0 8px; margin: 8px 0; @@ -123,6 +122,15 @@ } } +.chat-confirmation-widget .chat-buttons-container, +.chat-confirmation-widget .chat-confirmation-widget-message { + padding: 0 8px; +} + +.chat-confirmation-widget2 > .chat-confirmation-widget-message-scrollable { + min-height: 0; +} + .chat-confirmation-widget .chat-confirmation-widget-message-container { border: 1px solid var(--vscode-chat-requestBorder); border-radius: var(--vscode-cornerRadius-medium); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css index 8ab73acb9b684..06b92371129fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css @@ -23,12 +23,16 @@ color: var(--vscode-foreground); display: flex; flex-direction: column; - max-height: min(420px, 45vh); + max-height: min(300px, 45vh); border: 1px solid var(--vscode-input-border, transparent); border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-panel-background); overflow: hidden; + &:focus:not(:focus-visible) { + outline: none; + } + a { color: var(--vscode-foreground); @@ -89,6 +93,12 @@ margin-left: auto; } + button:focus:not(:focus-visible), + .monaco-button:focus:not(:focus-visible) { + outline: none !important; + box-shadow: none !important; + } + .chat-tool-carousel-nav-arrows { display: flex; align-items: center; @@ -132,6 +142,10 @@ background: var(--vscode-toolbar-hoverBackground) !important; } + &.chat-tool-carousel-content-expanded { + max-height: min(650px, 70vh); + } + &.chat-tool-carousel-collapsed { .chat-tool-carousel-content { display: none; @@ -160,16 +174,28 @@ flex: 1; .chat-tool-invocation-part { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; margin: 0; } .chat-confirmation-widget-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; margin: 0; border: none; } .chat-confirmation-widget, .chat-confirmation-widget2 { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; border: none; border-radius: 0; margin: 0; @@ -178,24 +204,42 @@ .chat-confirmation-widget-message-container { display: flex; flex-direction: column; + flex: 1; + min-height: 0; border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-border, transparent)); border-radius: var(--vscode-cornerRadius-medium); overflow: hidden; } + .chat-confirmation-widget-message-scrollable { + flex: 1; + min-height: 0; + } + .chat-confirmation-widget-message { - overflow: auto; + flex: 1; + min-height: 0; max-height: min(200px, 30vh); } .chat-confirmation-message-terminal-editor .interactive-result-code-block { - max-height: min(150px, 25vh); - overflow: auto; + background-color: var(--vscode-sideBar-background); + } + + .chat-confirmation-message-terminal-editor .interactive-result-editor { + background-color: var(--vscode-sideBar-background); + } + + .chat-tool-carousel-content-expanded & { + .chat-confirmation-widget-message { + max-height: min(500px, 55vh); + } } .chat-buttons-container { flex-shrink: 0; } + .interactive-result-code-block { border: none; border-radius: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts index 54ead8692b57e..99e754139a85b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts @@ -10,14 +10,21 @@ import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../../base/common/observable.js'; +import { generateUuid } from '../../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../../nls.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; import { IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { ChatToolInvocationPart } from './chatToolInvocationPart.js'; import '../media/chatToolConfirmationCarousel.css'; +const COLLAPSED_CAROUSEL_MAX_HEIGHT = 300; +const COLLAPSED_MESSAGE_MAX_HEIGHT = 200; +const COLLAPSED_CODE_BLOCK_MAX_HEIGHT = 150; +const MIN_CAROUSEL_MAX_HEIGHT = 80; +const EXPANDABLE_CONTENT_SELECTOR = '.interactive-result-editor, .chat-markdown-part.rendered-markdown'; + export type ToolInvocationPartFactory = (tool: IChatToolInvocation) => ChatToolInvocationPart; export type ScrollToSubagentCallback = (subAgentInvocationId: string) => void; @@ -50,8 +57,15 @@ export class ChatToolConfirmationCarouselPart extends Disposable { private readonly prevButton: Button; private readonly nextButton: Button; private readonly allowAllButton: Button; + private readonly expandContentButton: Button; private readonly collapseButton: Button; + private readonly activeContentDisposables: DisposableStore; + private readonly contentResizeObserver: dom.DisposableResizeObserver; + private readonly updateContentExpansionStateScheduler: dom.AnimationFrameScheduler; private _isCollapsed = false; + private _isContentExpanded = false; + private canExpandContent = false; + private maxHeight: number | undefined; constructor( private readonly toolPartFactory: ToolInvocationPartFactory, @@ -83,17 +97,31 @@ export class ChatToolConfirmationCarouselPart extends Disposable { this.collapsedTitle = elements.collapsedTitle; this.agentLabel = elements.agentLabel; this.contentContainer = elements.content; + this.contentContainer.id = generateUuid(); this.stepIndicator = elements.stepIndicator; + this.activeContentDisposables = this._register(new DisposableStore()); + this.updateContentExpansionStateScheduler = this._register(new dom.AnimationFrameScheduler(this.domNode, () => this.updateContentExpansionState())); + this.contentResizeObserver = this._register(new dom.DisposableResizeObserver(() => this.updateContentExpansionStateScheduler.schedule())); + this._register(this.contentResizeObserver.observe(this.contentContainer)); this.allowAllButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, small: true })); this.allowAllButton.element.classList.add('chat-tool-carousel-allow-all-button'); this.allowAllButton.label = localize('allowAll', "Allow All"); this._register(this.allowAllButton.onDidClick(() => this.allowAll())); + this.expandContentButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + this.expandContentButton.element.classList.add('chat-tool-carousel-header-button', 'chat-tool-carousel-expand-content-button'); + this.expandContentButton.element.setAttribute('aria-controls', this.contentContainer.id); + this.updateExpandContentButton(); + dom.hide(this.expandContentButton.element); + this._register(this.expandContentButton.onDidClick(() => this.toggleContentExpanded())); + this.collapseButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true })); this.collapseButton.element.classList.add('chat-tool-carousel-header-button'); this.collapseButton.label = `$(${Codicon.chevronDown.id})`; this.collapseButton.element.setAttribute('aria-label', localize('collapse', "Collapse")); + this.collapseButton.element.setAttribute('aria-controls', this.contentContainer.id); + this.collapseButton.element.setAttribute('aria-expanded', 'true'); this._register(this.collapseButton.onDidClick(() => this.toggleCollapse())); this.prevButton = this._register(new Button(elements.navArrows, { @@ -132,6 +160,11 @@ export class ChatToolConfirmationCarouselPart extends Disposable { return this.items.length; } + setMaxHeight(maxHeight: number | undefined): void { + this.maxHeight = maxHeight; + this.updateContentExpansionState(); + } + hasToolInvocation(toolCallId: string): boolean { return this.toolCallIds.has(toolCallId); } @@ -139,7 +172,7 @@ export class ChatToolConfirmationCarouselPart extends Disposable { addToolInvocation(tool: IChatToolInvocation, subAgentInvocationId?: string, agentName?: string, scrollToSubagent?: ScrollToSubagentCallback, toolPart?: ChatToolInvocationPart): void { if (this.toolCallIds.has(tool.toolCallId)) { const existing = this.items.find(item => item.toolCallId === tool.toolCallId); - if (existing && toolPart) { + if (existing && toolPart && !existing.toolPart) { this.replaceExternalToolPart(existing, toolPart); } return; @@ -199,7 +232,8 @@ export class ChatToolConfirmationCarouselPart extends Disposable { let isItemAlive = true; item.disposables.add(toDisposable(() => isItemAlive = false)); - toolPart.addDisposable(toDisposable(() => { + const externalPartDisposeWatcher = new MutableDisposable(); + externalPartDisposeWatcher.value = toDisposable(() => { if (!isItemAlive || item.toolPart !== toolPart) { return; } @@ -209,7 +243,9 @@ export class ChatToolConfirmationCarouselPart extends Disposable { if (this.items[this.activeIndex] === item) { this.renderActiveContent(); } - })); + }); + toolPart.addDisposable(externalPartDisposeWatcher); + item.disposables.add(toDisposable(() => externalPartDisposeWatcher.clear())); } override dispose(): void { @@ -330,7 +366,21 @@ export class ChatToolConfirmationCarouselPart extends Disposable { this.collapseButton.element.setAttribute('aria-label', this._isCollapsed ? localize('expand', "Expand") : localize('collapse', "Collapse") ); + this.collapseButton.element.setAttribute('aria-expanded', String(!this._isCollapsed)); + if (this._isCollapsed) { + this._isContentExpanded = false; + } this.updateUI(); + this.updateContentExpansionState(); + } + + private toggleContentExpanded(): void { + if (!this.canExpandContent) { + return; + } + + this._isContentExpanded = !this._isContentExpanded; + this.updateContentExpansionState(); } private updateUI(): void { @@ -367,17 +417,23 @@ export class ChatToolConfirmationCarouselPart extends Disposable { dom.setVisibility(multi, this.prevButton.element); dom.setVisibility(multi, this.nextButton.element); dom.setVisibility(this._isCollapsed || multi, this.allowAllButton.element); + dom.setVisibility(!this._isCollapsed && this.canExpandContent, this.expandContentButton.element); this.allowAllButton.label = multi ? localize('allowAll', "Allow All") : localize('allow', "Allow"); + this.updateExpandContentButton(); } private renderActiveContent(): void { dom.clearNode(this.contentContainer); + this.activeContentDisposables.clear(); + this._isContentExpanded = false; + this.canExpandContent = false; const item = this.items[this.activeIndex]; if (!item) { + this.updateContentExpansionState(); return; } @@ -389,6 +445,106 @@ export class ChatToolConfirmationCarouselPart extends Disposable { } this.contentContainer.appendChild(item.toolPart.domNode); + this.activeContentDisposables.add(this.contentResizeObserver.observe(item.toolPart.domNode)); + this.observeExpandableContentElements(item.toolPart.domNode); + this.updateContentExpansionStateScheduler.schedule(); + } + + private updateContentExpansionState(): void { + this.canExpandContent = !this._isCollapsed && this.items.length > 0 && this.isActiveContentLargerThanCollapsedLimit(); + if (!this.canExpandContent) { + this._isContentExpanded = false; + } + + this.domNode.classList.toggle('chat-tool-carousel-content-expanded', this.canExpandContent && this._isContentExpanded); + this.updateMaxHeightStyle(); + dom.setVisibility(!this._isCollapsed && this.canExpandContent, this.expandContentButton.element); + this.updateExpandContentButton(); + } + + private updateMaxHeightStyle(): void { + if (this.maxHeight === undefined) { + this.domNode.style.removeProperty('max-height'); + return; + } + + const expanded = this.canExpandContent && this._isContentExpanded; + const maxHeight = expanded ? Math.max(MIN_CAROUSEL_MAX_HEIGHT, this.maxHeight) : this.getCollapsedMaxHeight(); + this.domNode.style.maxHeight = `${Math.floor(maxHeight)}px`; + } + + private updateExpandContentButton(): void { + const expanded = this.canExpandContent && this._isContentExpanded; + const label = expanded + ? localize('restoreConfirmationSize', "Restore Confirmation Size") + : localize('expandConfirmationUp', "Expand Confirmation Up"); + this.expandContentButton.label = expanded + ? `$(${Codicon.screenNormal.id})` + : `$(${Codicon.screenFull.id})`; + this.expandContentButton.element.setAttribute('aria-label', label); + this.expandContentButton.element.setAttribute('aria-expanded', String(expanded)); + this.expandContentButton.setTitle(label); + } + + private isActiveContentLargerThanCollapsedLimit(): boolean { + const activeContent = this.contentContainer.firstElementChild; + if (!dom.isHTMLElement(activeContent)) { + return false; + } + + return this.hasInnerContentLargerThanCollapsedLimit(activeContent); + } + + private hasInnerContentLargerThanCollapsedLimit(element: HTMLElement): boolean { + if (this.isExpandableContentElement(element) && this.getElementHeight(element) > this.getExpandableContentHeightLimit(element) + 1) { + return true; + } + + for (const child of element.children) { + if (!dom.isHTMLElement(child)) { + continue; + } + + if (this.hasInnerContentLargerThanCollapsedLimit(child)) { + return true; + } + } + + return false; + } + + private isExpandableContentElement(element: HTMLElement): boolean { + return element.matches(EXPANDABLE_CONTENT_SELECTOR); + } + + private observeExpandableContentElements(element: HTMLElement): void { + if (this.isExpandableContentElement(element)) { + this.activeContentDisposables.add(this.contentResizeObserver.observe(element)); + } + + for (const child of element.children) { + if (dom.isHTMLElement(child)) { + this.observeExpandableContentElements(child); + } + } + } + + private getElementHeight(element: HTMLElement): number { + return Math.max(element.offsetHeight, element.scrollHeight); + } + + private getExpandableContentHeightLimit(element: HTMLElement): number { + const window = dom.getWindow(this.domNode); + if (element.classList.contains('interactive-result-editor')) { + return Math.min(COLLAPSED_CODE_BLOCK_MAX_HEIGHT, window.innerHeight * 0.25); + } + + return Math.min(COLLAPSED_MESSAGE_MAX_HEIGHT, window.innerHeight * 0.3); + } + + private getCollapsedMaxHeight(): number { + const configuredMaxHeight = this.maxHeight === undefined ? Number.POSITIVE_INFINITY : Math.max(MIN_CAROUSEL_MAX_HEIGHT, this.maxHeight); + return Math.min(configuredMaxHeight, COLLAPSED_CAROUSEL_MAX_HEIGHT, dom.getWindow(this.domNode).innerHeight * 0.45); } allowAll(): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 7485a5b8f0a84..6f2a5e4e20c1e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2148,11 +2148,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { const currentState = toolInvocation.state.read(reader); if (currentState.type === IChatToolInvocation.StateKind.WaitingForConfirmation && currentState.confirmationMessages?.title) { - widget.inputPart.addToolToConfirmationCarousel(toolInvocation, factory); - dom.hide(part.domNode); + widget.inputPart.addToolToConfirmationCarousel(toolInvocation, factory, undefined, undefined, undefined, part); } else if (IChatToolInvocation.isEffectivelyHidden(toolInvocation, reader)) { dom.hide(part.domNode); } else { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 7b41891dab309..9bba74565ec5b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2471,6 +2471,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.renderAttachedContext(); const inputResizeObserver = this._register(new dom.DisposableResizeObserver(() => { + this.updateToolConfirmationCarouselMaxHeight(); const newHeight = this.container.offsetHeight; this.height.set(newHeight, undefined); })); @@ -2859,6 +2860,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const existing = this._currentToolConfirmationCarousel; if (existing) { existing.addToolInvocation(tool, subAgentInvocationId, agentName, scrollToSubagent, toolPart); + this.updateToolConfirmationCarouselMaxHeight(); return existing; } @@ -2872,6 +2874,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatToolConfirmationCarousels.set(key, part); dom.append(this.chatToolConfirmationCarouselContainer, part.domNode); dom.show(this.chatToolConfirmationCarouselContainer); + this.updateToolConfirmationCarouselMaxHeight(); const capturedKey = key; Event.once(part.onDidEmpty)(() => { @@ -2889,6 +2892,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const existing = this._currentToolConfirmationCarousel; if (existing) { existing.addToolInvocation(tool, subAgentInvocationId, agentName, scrollToSubagent, toolPart); + this.updateToolConfirmationCarouselMaxHeight(); } else { this.renderToolConfirmationCarousel(tool, factory, subAgentInvocationId, agentName, scrollToSubagent, toolPart); } @@ -2928,6 +2932,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (carousel && carousel.pendingCount > 0) { dom.append(this.chatToolConfirmationCarouselContainer, carousel.domNode); dom.show(this.chatToolConfirmationCarouselContainer); + this.updateToolConfirmationCarouselMaxHeight(); } else { dom.hide(this.chatToolConfirmationCarouselContainer); } @@ -3272,6 +3277,23 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ setMaxHeight(maxHeight: number | undefined): void { this._maxHeight = maxHeight; + this.updateToolConfirmationCarouselMaxHeight(); + } + + private updateToolConfirmationCarouselMaxHeight(): void { + const carousel = this._currentToolConfirmationCarousel; + if (!carousel) { + return; + } + + if (this._maxHeight === undefined) { + carousel.setMaxHeight(undefined); + return; + } + + const carouselHeight = this.chatToolConfirmationCarouselContainer.offsetHeight; + const otherInputHeight = Math.max(0, this.container.offsetHeight - carouselHeight); + carousel.setMaxHeight(this._maxHeight - otherInputHeight); } /** From fad4734378dc13570d1423bf389fd86c951422f7 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:25:18 -0700 Subject: [PATCH 32/35] Sessions: filter out Claude customization directories (#309105) * Filter out Claude customization directories in sessions app The sessions (Agents) window was incorrectly showing and allowing creation of customizations in .claude directories. Add protected hook methods getPromptSourceFolders() and getDefaultSourceFolders() to PromptFilesLocator, then override them in AgenticPromptFilesLocator to filter out Claude-specific sources (ClaudePersonal, ClaudeWorkspace, ClaudeWorkspaceLocal). This ensures the sessions app only shows .github and .copilot customization directories. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: path-based Claude filtering and fix JSDoc - Also filter user-configured .claude paths (Config* source types) by checking the path string, not just the PromptFileSource enum - Fix inaccurate JSDoc comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/promptsService.ts | 31 ++++++++++++++++-- .../promptSyntax/utils/promptFilesLocator.ts | 32 ++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 63d6748ed4e86..f0496e24d3550 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -14,8 +14,8 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { HOOKS_SOURCE_FOLDER, SKILL_FILENAME } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; -import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { HOOKS_SOURCE_FOLDER, IPromptSourceFolder, SKILL_FILENAME } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { PromptFileSource, PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IAgentSkill, IPromptPath, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { BUILTIN_STORAGE, IBuiltinPromptPath } from '../../chat/common/builtinPromptsStorage.js'; import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; @@ -253,6 +253,18 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { return Event.fromObservableLight(this.customizationWorkspaceService.activeProjectRoot); } + /** + * Filter out Claude-specific source folders in the sessions app. + * Claude customization directories are not supported in the sessions window. + */ + protected override getPromptSourceFolders(type: PromptsType): IPromptSourceFolder[] { + return super.getPromptSourceFolders(type).filter(f => !isClaudeFolder(f)); + } + + protected override getDefaultSourceFolders(type: PromptsType): readonly IPromptSourceFolder[] { + return super.getDefaultSourceFolders(type).filter(f => !isClaudeFolder(f)); + } + public override async getHookSourceFolders(): Promise { const configured = await super.getHookSourceFolders(); if (configured.length > 0) { @@ -301,3 +313,18 @@ function sanitizeSkillText(text: string, maxLength: number): string { return sanitized.length > maxLength ? sanitized.substring(0, maxLength) : sanitized; } +/** + * Returns whether the given source folder targets a Claude-specific location. + * Checks both the typed source enum and the path string to also catch + * user-configured entries that use ConfigWorkspace/ConfigPersonal sources. + */ +function isClaudeFolder(folder: IPromptSourceFolder): boolean { + if (folder.source === PromptFileSource.ClaudePersonal + || folder.source === PromptFileSource.ClaudeWorkspace + || folder.source === PromptFileSource.ClaudeWorkspaceLocal) { + return true; + } + // User-configured paths get Config* source types, so also check the path + return folder.path.startsWith('.claude/') || folder.path.includes('/.claude/'); +} + diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 6da404c852043..2c4dc344c1dfd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -81,6 +81,22 @@ export class PromptFilesLocator { return Event.map(this.workspaceService.onDidChangeWorkspaceFolders, () => undefined); } + /** + * Returns the configured prompt source folders for the given type. + * Subclasses can override to filter out unsupported sources. + */ + protected getPromptSourceFolders(type: PromptsType): IPromptSourceFolder[] { + return PromptsConfig.promptSourceFolders(this.configService, type); + } + + /** + * Returns the default prompt source folders for the given type. + * Subclasses can override to filter out unsupported sources. + */ + protected getDefaultSourceFolders(type: PromptsType): readonly IPromptSourceFolder[] { + return getPromptFileDefaultLocations(type); + } + public async getWorkspaceFolderRoots(includeParents: boolean, logger?: Logger): Promise { const workspaceFolders = this.getWorkspaceFolders(); if (includeParents) { @@ -152,7 +168,7 @@ export class PromptFilesLocator { throw new Error(`Unsupported prompt file storage: ${storage}`); } - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); + const configuredLocations = this.getPromptSourceFolders(type); const absoluteLocations = await this.toAbsoluteLocations(type, configuredLocations.filter(loc => loc.storage === storage)); if (storage === PromptsStorage.user && (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt)) { @@ -201,7 +217,7 @@ export class PromptFilesLocator { const update = async () => { try { - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); + const configuredLocations = this.getPromptSourceFolders(type); parentFolders = await this.toAbsoluteLocations(type, configuredLocations, undefined); if (token.isCancellationRequested) { @@ -245,10 +261,10 @@ export class PromptFilesLocator { /** * Gets the hook source folders for creating new hooks. - * Returns folders from config, excluding user storage and Claude paths (which are read-only). + * Returns configured hook folders, excluding Claude paths (which are read-only). */ public async getHookSourceFolders(): Promise { - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.hook); + const configuredLocations = this.getPromptSourceFolders(PromptsType.hook); // Ignore claude folders since they aren't first-class supported, so we don't want to create invalid formats // Check for .claude as an actual path segment (starts with ".claude/" or contains "/.claude/") @@ -283,7 +299,7 @@ export class PromptFilesLocator { * @returns List of possible unambiguous prompt file folders. */ public async getConfigBasedSourceFolders(type: PromptsType): Promise { - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); + const configuredLocations = this.getPromptSourceFolders(type); const absoluteLocations = await this.toAbsoluteLocations(type, configuredLocations); // For anything that doesn't support glob patterns, we can return @@ -364,8 +380,8 @@ export class PromptFilesLocator { * This merges default folders with configured locations. */ private async getLocalStorageFolders(type: PromptsType): Promise { - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); - const defaultFolders = getPromptFileDefaultLocations(type); + const configuredLocations = this.getPromptSourceFolders(type); + const defaultFolders = this.getDefaultSourceFolders(type); // Merge default folders with configured locations, avoiding duplicates const allFolders = [ @@ -724,7 +740,7 @@ export class PromptFilesLocator { * Searches for skills in all configured locations. */ public async findAgentSkills(token: CancellationToken): Promise { - const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.skill); + const configuredLocations = this.getPromptSourceFolders(PromptsType.skill); const absoluteLocations = await this.toAbsoluteLocations(PromptsType.skill, configuredLocations); const allResults: IPromptPath[] = []; From 0999c0e18e30709742e1318946d588a73152f9f8 Mon Sep 17 00:00:00 2001 From: Robo Date: Sat, 11 Apr 2026 06:27:49 +0900 Subject: [PATCH 33/35] fix: update OS display name of the agents app (#308992) * fix: update OS display name of the agents app * fix: remove old start menu shortcut * temp: bump gulp-electron for validation * temp: bump gulp-electron * chore: update sign.ts * fix: entitlements for embedded app helpers * fix: launch services registration * chore: bump gulp-electron@1.41.2 --- build/darwin/sign.ts | 20 ++++++++------------ build/gulpfile.vscode.ts | 1 + build/win32/code.iss | 6 +++++- package-lock.json | 8 ++++---- package.json | 2 +- src/vs/code/electron-main/app.ts | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/build/darwin/sign.ts b/build/darwin/sign.ts index dc411d5e1fe8d..26e22aee08c88 100644 --- a/build/darwin/sign.ts +++ b/build/darwin/sign.ts @@ -11,10 +11,6 @@ import { spawn } from '@malept/cross-spawn-promise'; const root = path.dirname(path.dirname(import.meta.dirname)); const baseDir = path.dirname(import.meta.dirname); const product = JSON.parse(fs.readFileSync(path.join(root, 'product.json'), 'utf8')); -const helperAppBaseName = product.nameShort; -const gpuHelperAppName = helperAppBaseName + ' Helper (GPU).app'; -const rendererHelperAppName = helperAppBaseName + ' Helper (Renderer).app'; -const pluginHelperAppName = helperAppBaseName + ' Helper (Plugin).app'; function getElectronVersion(): string { const npmrc = fs.readFileSync(path.join(root, '.npmrc'), 'utf8'); @@ -23,11 +19,11 @@ function getElectronVersion(): string { } function getEntitlementsForFile(filePath: string): string { - if (filePath.includes(gpuHelperAppName)) { + if (filePath.includes(' Helper (GPU).app')) { return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-gpu-entitlements.plist'); - } else if (filePath.includes(rendererHelperAppName)) { + } else if (filePath.includes(' Helper (Renderer).app')) { return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-renderer-entitlements.plist'); - } else if (filePath.includes(pluginHelperAppName)) { + } else if (filePath.includes(' Helper (Plugin).app')) { return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-plugin-entitlements.plist'); } return path.join(baseDir, 'azure-pipelines', 'darwin', 'app-entitlements.plist'); @@ -79,7 +75,7 @@ async function main(buildDir?: string): Promise { const appName = product.nameLong + '.app'; const infoPlistPath = path.resolve(appRoot, appName, 'Contents', 'Info.plist'); const embeddedInfoPlistPath = product.embedded - ? path.resolve(appRoot, appName, 'Contents', 'Applications', `${product.embedded.nameShort}.app`, 'Contents', 'Info.plist') + ? path.resolve(appRoot, appName, 'Contents', 'Applications', `${product.embedded.nameLong}.app`, 'Contents', 'Info.plist') : undefined; const appOpts: SignOptions = { @@ -140,28 +136,28 @@ async function main(buildDir?: string): Promise { '-insert', 'NSAppleEventsUsageDescription', '-string', - `An application in ${product.embedded.nameShort} wants to use AppleScript.`, + `An application in ${product.embedded.nameLong} wants to use AppleScript.`, `${embeddedInfoPlistPath}` ]); await spawn('plutil', [ '-replace', 'NSMicrophoneUsageDescription', '-string', - `An application in ${product.embedded.nameShort} wants to use the Microphone.`, + `An application in ${product.embedded.nameLong} wants to use the Microphone.`, `${embeddedInfoPlistPath}` ]); await spawn('plutil', [ '-replace', 'NSCameraUsageDescription', '-string', - `An application in ${product.embedded.nameShort} wants to use the Camera.`, + `An application in ${product.embedded.nameLong} wants to use the Camera.`, `${embeddedInfoPlistPath}` ]); await spawn('plutil', [ '-replace', 'NSAudioCaptureUsageDescription', '-string', - `An application in ${product.embedded.nameShort} wants to use Audio Capture.`, + `An application in ${product.embedded.nameLong} wants to use Audio Capture.`, `${embeddedInfoPlistPath}` ]); await spawn('plutil', [ diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 25ba5e88ad8af..e4e867271cfde 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -540,6 +540,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d ffmpegChromium: false, ...(embedded ? { darwinMiniAppName: embedded.nameShort, + darwinMiniAppDisplayName: embedded.nameLong, darwinMiniAppBundleIdentifier: embedded.darwinBundleIdentifier, darwinMiniAppIcon: 'resources/darwin/agents.icns', darwinMiniAppAssetsCar: 'resources/darwin/agents.car', diff --git a/build/win32/code.iss b/build/win32/code.iss index c1f954d290fb9..7f23530dcb1a7 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -74,6 +74,10 @@ Type: filesandordirs; Name: "{app}\{#VersionedResourcesFolder}\resources\app\nod Type: filesandordirs; Name: "{app}\{#VersionedResourcesFolder}\resources\app\node_modules.asar.unpacked"; Check: IsNotBackgroundUpdate Type: files; Name: "{app}\{#VersionedResourcesFolder}\resources\app\node_modules.asar"; Check: IsNotBackgroundUpdate Type: files; Name: "{app}\{#VersionedResourcesFolder}\resources\app\Credits_45.0.2454.85.html"; Check: IsNotBackgroundUpdate +#ifdef ProxyExeBasename +; Clean up legacy Start Menu shortcut that used ProxyExeBasename instead of ProxyNameLong +Type: files; Name: "{group}\{#ProxyExeBasename}.lnk" +#endif [UninstallDelete] Type: filesandordirs; Name: "{app}\_" @@ -117,7 +121,7 @@ Name: "{group}\{#NameLong}"; Filename: "{app}\{#ExeBasename}.exe"; AppUserModelI Name: "{autodesktop}\{#NameLong}"; Filename: "{app}\{#ExeBasename}.exe"; Tasks: desktopicon; AppUserModelID: "{#AppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{autodesktop}\{#NameLong}.lnk')) Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#NameLong}"; Filename: "{app}\{#ExeBasename}.exe"; Tasks: quicklaunchicon; AppUserModelID: "{#AppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#NameLong}.lnk')) #ifdef ProxyExeBasename -Name: "{group}\{#ProxyExeBasename}"; Filename: "{app}\{#ProxyExeBasename}.exe"; AppUserModelID: "{#ProxyAppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{group}\{#ProxyExeBasename}.lnk')) +Name: "{group}\{#ProxyNameLong}"; Filename: "{app}\{#ProxyExeBasename}.exe"; AppUserModelID: "{#ProxyAppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{group}\{#ProxyNameLong}.lnk')) Name: "{autodesktop}\{#ProxyNameLong}"; Filename: "{app}\{#ProxyExeBasename}.exe"; Tasks: desktopicon; AppUserModelID: "{#ProxyAppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{autodesktop}\{#ProxyNameLong}.lnk')) Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#ProxyNameLong}"; Filename: "{app}\{#ProxyExeBasename}.exe"; Tasks: quicklaunchicon; AppUserModelID: "{#ProxyAppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#ProxyNameLong}.lnk')) #endif diff --git a/package-lock.json b/package-lock.json index 755b4fe3aef79..cefb186f872dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260408", "@vscode/component-explorer": "^0.2.1-8", "@vscode/component-explorer-cli": "^0.2.1-7", - "@vscode/gulp-electron": "1.41.0", + "@vscode/gulp-electron": "1.41.2", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", @@ -3459,9 +3459,9 @@ } }, "node_modules/@vscode/gulp-electron": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.41.0.tgz", - "integrity": "sha512-eWieEKuR8yYSIBOkR0UBTnzIfp3tJn8iyaw3dgz4cTJAsMjeDmKsl9atVVa773ua6NOzUMhqy2PuVuw24vvU5w==", + "version": "1.41.2", + "resolved": "git+ssh://git@github.com/microsoft/vscode-gulp-electron.git#d44aa01b0ac0e0d71b83f1f9d68fea8aff79b7f1", + "integrity": "sha512-1g/8LIKcL6J8q2Rljj3cMqRVlXRntSiEjP6e5nGcbjSLuxRlOZFyCMTkf7ag3q5MxVy/iDC2ihWgBt0O2BwJMQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c3a544010f214..6dbf10c9dc80e 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260408", "@vscode/component-explorer": "^0.2.1-8", "@vscode/component-explorer-cli": "^0.2.1-7", - "@vscode/gulp-electron": "1.41.0", + "@vscode/gulp-electron": "1.41.2", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 1e5fd2bfeae2b..a6a7a326c6b1f 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -1755,7 +1755,7 @@ export class CodeApplication extends Disposable { } // appRoot points to Contents/Resources/app on macOS - const embeddedAppPath = join(this.environmentMainService.appRoot, '..', '..', 'Applications', `${this.productService.embedded.nameShort}.app`); + const embeddedAppPath = join(this.environmentMainService.appRoot, '..', '..', 'Applications', `${this.productService.embedded.nameLong}.app`); const lsregister = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; this.logService.trace('Registering embedded app with Launch Services:', embeddedAppPath); const child = execFile(lsregister, ['-f', embeddedAppPath], { timeout: 30_000 }, (error) => { From 3e371922e0093948961395e7a23fc1cb35837f4b Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:42:20 -0400 Subject: [PATCH 34/35] product: rename GHE.com label to GHE (#309106) Update the GitHub Enterprise auth provider label in product.json to use the shorter GHE name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- product.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/product.json b/product.json index 09a0a3e633144..3daf8f584e181 100644 --- a/product.json +++ b/product.json @@ -105,7 +105,7 @@ }, "enterprise": { "id": "github-enterprise", - "name": "GHE.com" + "name": "GHE" }, "google": { "id": "google", From 6e637e5cab72666ac45a04794b948e89ca51b094 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:16:46 -0700 Subject: [PATCH 35/35] fix: AI customization welcome page - prefill active session & update placeholder (#309112) * fix: AI customization welcome page improvements - Fix 'New...' button to insert into active session's chat input instead of always targeting the new-session view - Update placeholder text to be more workflow-oriented Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: register IChatWidgetService mock in fixture Add missing IChatWidgetService mock to the aiCustomizationManagementEditor fixture to prevent crash after the new service dependency was added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: rename 'Generate Workflow' to 'Customize Your Agent' Update the getting started section title, description, and aria labels to better reflect the purpose of the customization input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationManagementEditor.ts | 28 +++++++++++++------ ...CustomizationWelcomePagePromptLaunchers.ts | 10 +++---- ...aiCustomizationManagementEditor.fixture.ts | 5 ++++ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 8b81fbd968a74..170e64f301108 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -88,6 +88,7 @@ import { ICustomizationHarnessService, CustomizationHarness, matchesWorkspaceSub import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage } from './aiCustomizationWelcomePage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { IChatWidgetService } from '../chat.js'; const $ = DOM.$; @@ -349,6 +350,7 @@ export class AICustomizationManagementEditor extends EditorPane { @INotificationService private readonly notificationService: INotificationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IViewsService private readonly viewsService: IViewsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -814,15 +816,23 @@ export class AICustomizationManagementEditor extends EditorPane { }, prefillChat: (query, options) => { if (this.workspaceService.isSessionsWindow) { - const sessionsViewId = 'workbench.view.sessions.chat'; - this.viewsService.openView(sessionsViewId, true).then(view => { - const chatView = view as unknown as { prefillInput?(text: string): void; sendQuery?(text: string): void } | undefined; - if (options?.isPartialQuery && chatView?.prefillInput) { - chatView.prefillInput(query); - } else if (chatView?.sendQuery) { - chatView.sendQuery(query); - } - }); + const widget = this.chatWidgetService.lastFocusedWidget; + if (widget) { + this.chatWidgetService.reveal(widget).then(() => { + widget.setInput(query); + widget.focusInput(); + }); + } else { + const sessionsViewId = 'workbench.view.sessions.chat'; + this.viewsService.openView(sessionsViewId, true).then(view => { + const chatView = view as unknown as { prefillInput?(text: string): void; sendQuery?(text: string): void } | undefined; + if (options?.isPartialQuery && chatView?.prefillInput) { + chatView.prefillInput(query); + } else if (chatView?.sendQuery) { + chatView.sendQuery(query); + } + }); + } } else { this.commandService.executeCommand('workbench.action.chat.open', { query, isPartialQuery: options?.isPartialQuery ?? false }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts index 208bb8f955ff3..69a31e7d84831 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts @@ -101,19 +101,19 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem const icon = DOM.append(header, $('span.welcome-prompts-section-label-icon.codicon.codicon-sparkle')); icon.setAttribute('aria-hidden', 'true'); const title = DOM.append(header, $('span')); - title.textContent = localize('gettingStartedTitle', "Generate Workflow"); + title.textContent = localize('gettingStartedTitle', "Customize Your Agent"); const description = DOM.append(gettingStarted, $('p.welcome-prompts-input-helper')); - description.textContent = localize('gettingStartedDesc', "Describe your stack, conventions, and workflow to draft agents, skills, and instructions."); + description.textContent = localize('gettingStartedDesc', "Describe your preferences and conventions to draft agents, skills, and instructions."); const inputRow = DOM.append(gettingStarted, $('.welcome-prompts-input-row')); this.inputElement = DOM.append(inputRow, $('input.welcome-prompts-input')) as HTMLInputElement; this.inputElement.type = 'text'; - this.inputElement.placeholder = localize('workflowInputPlaceholder', "I'm building a React app with TypeScript and Tailwind..."); - this.inputElement.setAttribute('aria-label', localize('workflowInputAriaLabel', "Describe your project to generate a workflow")); + this.inputElement.placeholder = localize('workflowInputPlaceholder', "Prefer concise commits, thorough reviews, and tested code..."); + this.inputElement.setAttribute('aria-label', localize('workflowInputAriaLabel', "Describe your preferences to customize your agent")); const submitBtn = DOM.append(inputRow, $('button.welcome-prompts-input-submit')); - submitBtn.setAttribute('aria-label', localize('workflowSubmitAriaLabel', "Generate workflow")); + submitBtn.setAttribute('aria-label', localize('workflowSubmitAriaLabel', "Customize agent")); const chevron = DOM.append(submitBtn, $('span.codicon.codicon-arrow-up')); chevron.setAttribute('aria-hidden', 'true'); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index c2cecd2b3e94b..6d9f30a89e80e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -24,6 +24,7 @@ import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../. import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; @@ -504,6 +505,10 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor reg.defineInstance(IViewsService, new class extends mock() { override async openView(_id: string, _focus?: boolean) { return null as T | null; } }()); + reg.defineInstance(IChatWidgetService, new class extends mock() { + override get lastFocusedWidget() { return undefined; } + override async reveal() { return false; } + }()); reg.defineInstance(IRequestService, new class extends mock() { }()); reg.defineInstance(IMarkdownRendererService, new class extends mock() { override render() {