From 8af869cc491f1dc7700d7a0d04492a380605b215 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 28 Aug 2026 16:38:55 +0000 Subject: [PATCH 01/41] Fix memory leaks when clearing test results --- .../testResultsView/testResultsViewContent.ts | 2 +- .../contrib/testing/common/testResultService.ts | 15 ++++++++++----- .../testing/test/common/testResultService.test.ts | 8 ++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts index b60894a7502547..69a37fc0a73570 100644 --- a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts +++ b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts @@ -250,7 +250,7 @@ export class TestResultsViewContent extends Disposable { public fillBody(containerElement: HTMLElement): void { const initialSpitWidth = TestResultsViewContent.lastSplitWidth; - this.splitView = new SplitView(containerElement, { orientation: Orientation.HORIZONTAL }); + this.splitView = this._register(new SplitView(containerElement, { orientation: Orientation.HORIZONTAL })); const { historyVisible, showRevealLocationOnMessages } = this.options; const isInPeekView = this.editor !== undefined; diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index 87c3210aeff0fb..d4f7698cd29094 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -76,7 +76,7 @@ export class TestResultService extends Disposable implements ITestResultService declare _serviceBrand: undefined; private changeResultEmitter = this._register(new Emitter()); private _results: ITestResult[] = []; - private readonly _resultsDisposables: DisposableStore[] = []; + private readonly _resultsDisposables = new Map(); private testChangeEmitter = this._register(new Emitter()); private insertOrderCounter = 0; @@ -115,7 +115,7 @@ export class TestResultService extends Disposable implements ITestResultService @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); - this._register(toDisposable(() => dispose(this._resultsDisposables))); + this._register(toDisposable(() => dispose(this._resultsDisposables.values()))); this.isRunning = TestingContextKeys.isRunning.bindTo(contextKeyService); this.hasAnyResults = TestingContextKeys.hasAnyResults.bindTo(contextKeyService); } @@ -182,12 +182,13 @@ export class TestResultService extends Disposable implements ITestResultService this.hasAnyResults.set(true); if (this.results.length > RETAIN_MAX_RESULTS) { - this.results.pop(); - this._resultsDisposables.pop()?.dispose(); + const removed = this.results.pop()!; + this._resultsDisposables.get(removed)?.dispose(); + this._resultsDisposables.delete(removed); } const ds = new DisposableStore(); - this._resultsDisposables.push(ds); + this._resultsDisposables.set(result, ds); if (result instanceof LiveTestResult) { ds.add(result); @@ -237,6 +238,10 @@ export class TestResultService extends Disposable implements ITestResultService } this._results = keep; + for (const result of removed) { + this._resultsDisposables.get(result)?.dispose(); + this._resultsDisposables.delete(result); + } this.persistScheduler.schedule(); if (keep.length === 0) { this.hasAnyResults.set(false); diff --git a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts index 272d02a8a836f9..6c4f47b3bb8d94 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -44,6 +44,8 @@ suite('Workbench - Test Results Service', () => { let insertCounter = 0; class TestLiveTestResult extends LiveTestResult { + public disposed = false; + constructor( id: string, persist: boolean, @@ -56,6 +58,11 @@ suite('Workbench - Test Results Service', () => { public setAllToStatePublic(state: TestResultState, taskId: string, when: (task: ITestTaskState, item: TestResultItem) => boolean) { this.setAllToState(state, taskId, when); } + + public override dispose(): void { + this.disposed = true; + super.dispose(); + } } const ds = ensureNoDisposablesAreLeakedInTestSuite(); @@ -272,6 +279,7 @@ suite('Workbench - Test Results Service', () => { results.clear(); assert.deepStrictEqual(results.results, [r2]); + assert.strictEqual(r.disposed, true); }); test('keeps ongoing tests on top, restored order when done', async () => { From eec850e3e2e56f81e552c6ea4783566be12bf2e0 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 28 Aug 2026 20:13:31 +0000 Subject: [PATCH 02/41] Fix disposal of immediately evicted test results --- .../contrib/testing/common/testResultService.ts | 14 ++++++++------ .../testing/test/common/testResultService.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index d4f7698cd29094..bceae781fdfa10 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -172,6 +172,14 @@ export class TestResultService extends Disposable implements ITestResultService * @inheritdoc */ public push(result: T): T { + if (result instanceof LiveTestResult) { + const ds = new DisposableStore(); + this._resultsDisposables.set(result, ds); + ds.add(result); + ds.add(result.onComplete(() => this.onComplete(result))); + ds.add(result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter)); + } + if (result.completedAt === undefined) { this.results.unshift(result); } else { @@ -187,13 +195,7 @@ export class TestResultService extends Disposable implements ITestResultService this._resultsDisposables.delete(removed); } - const ds = new DisposableStore(); - this._resultsDisposables.set(result, ds); - if (result instanceof LiveTestResult) { - ds.add(result); - ds.add(result.onComplete(() => this.onComplete(result))); - ds.add(result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter)); this.isRunning.set(true); this.changeResultEmitter.fire({ started: result }); } else { diff --git a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts index 6c4f47b3bb8d94..4faad9ae2ceac6 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -339,6 +339,19 @@ suite('Workbench - Test Results Service', () => { results.push(hydrated2); assert.deepStrictEqual(results.results, [r, hydrated1, hydrated2]); }); + + test('disposes a completed result that is immediately evicted', async () => { + const newerCompletedAt = Date.now() + 1000; + for (let i = 0; i < 128; i++) { + results.push(await makeHydrated(newerCompletedAt + i)); + } + + const older = new TestLiveTestResult('older', false, defaultOpts([])); + older.markComplete(); + results.push(older); + + assert.deepStrictEqual({ retained: results.results.includes(older), disposed: older.disposed }, { retained: false, disposed: true }); + }); }); test('resultItemParents', function () { From 9f878572424fbad96dffe5362a33343b93c18a4d Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Tue, 1 Sep 2026 14:30:43 +0000 Subject: [PATCH 03/41] Use DisposableMap for test result resources --- .../testing/common/testResultService.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index bceae781fdfa10..6e0c10447cb028 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -7,7 +7,7 @@ import { findFirstIdxMonotonousOrArrLen } from '../../../../base/common/arraysFi import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { createSingleCallFunction } from '../../../../base/common/functional.js'; -import { Disposable, DisposableStore, dispose, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -76,7 +76,7 @@ export class TestResultService extends Disposable implements ITestResultService declare _serviceBrand: undefined; private changeResultEmitter = this._register(new Emitter()); private _results: ITestResult[] = []; - private readonly _resultsDisposables = new Map(); + private readonly _resultsDisposables = this._register(new DisposableMap()); private testChangeEmitter = this._register(new Emitter()); private insertOrderCounter = 0; @@ -115,7 +115,6 @@ export class TestResultService extends Disposable implements ITestResultService @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); - this._register(toDisposable(() => dispose(this._resultsDisposables.values()))); this.isRunning = TestingContextKeys.isRunning.bindTo(contextKeyService); this.hasAnyResults = TestingContextKeys.hasAnyResults.bindTo(contextKeyService); } @@ -172,14 +171,6 @@ export class TestResultService extends Disposable implements ITestResultService * @inheritdoc */ public push(result: T): T { - if (result instanceof LiveTestResult) { - const ds = new DisposableStore(); - this._resultsDisposables.set(result, ds); - ds.add(result); - ds.add(result.onComplete(() => this.onComplete(result))); - ds.add(result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter)); - } - if (result.completedAt === undefined) { this.results.unshift(result); } else { @@ -189,10 +180,22 @@ export class TestResultService extends Disposable implements ITestResultService } this.hasAnyResults.set(true); + let removed: ITestResult | undefined; if (this.results.length > RETAIN_MAX_RESULTS) { - const removed = this.results.pop()!; - this._resultsDisposables.get(removed)?.dispose(); - this._resultsDisposables.delete(removed); + removed = this.results.pop(); + } + + const ds = new DisposableStore(); + this._resultsDisposables.set(result, ds); + + if (result instanceof LiveTestResult) { + ds.add(result); + ds.add(result.onComplete(() => this.onComplete(result))); + ds.add(result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter)); + } + + if (removed) { + this._resultsDisposables.deleteAndDispose(removed); } if (result instanceof LiveTestResult) { @@ -241,8 +244,7 @@ export class TestResultService extends Disposable implements ITestResultService this._results = keep; for (const result of removed) { - this._resultsDisposables.get(result)?.dispose(); - this._resultsDisposables.delete(result); + this._resultsDisposables.deleteAndDispose(result); } this.persistScheduler.schedule(); if (keep.length === 0) { From 867755f06d0a91a7f9b97e9f0a3c69467274a482 Mon Sep 17 00:00:00 2001 From: roblourens Date: Tue, 1 Sep 2026 09:39:45 -0700 Subject: [PATCH 04/41] agentHost: preserve remote sessions across reload (#333251) agentHost: preserve client-addressed session directories Keep non-file working directory URIs returned by listSessions in client space so remote workspace filtering treats live and restored sessions consistently.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 8 +-- .../agentHostProtocolClient.test.ts | 53 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index d79e11e400d225..ade98d06b9c829 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -1437,22 +1437,22 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect modifiedTime: Date.parse(s.modifiedAt), ...(s.project ? { project: { - uri: this._toLocalProjectUri(URI.parse(s.project.uri)), + uri: this._toClientUri(URI.parse(s.project.uri)), displayName: s.project.displayName, } } : {}), summary: s.title, status: s.status, activity: s.activity, - workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? toAgentHostUri(URI.parse(s.workingDirectories?.[0]), this._connectionAuthority) : undefined, - workingDirectories: s.workingDirectories?.map(d => toAgentHostUri(URI.parse(d), this._connectionAuthority)), + workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? this._toClientUri(URI.parse(s.workingDirectories[0])) : undefined, + workingDirectories: s.workingDirectories?.map(d => this._toClientUri(URI.parse(d))), changes: s.changes, // Carry durable host provenance for sessions first materialized from a listing. ...(s._meta !== undefined ? { _meta: s._meta } : {}), })); } - private _toLocalProjectUri(uri: URI): URI { + private _toClientUri(uri: URI): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, this._connectionAuthority) : uri; } diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 06fc392df17c88..2de99cfa54a49d 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -11,6 +11,7 @@ import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; +import { extUriBiasedIgnorePathCase } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -557,6 +558,58 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(sessions.map(s => readSessionExternal(s._meta)), [true]); }); + test('listSessions preserves client-addressed remote working directories across reload', async () => { + const { client, transport } = createClient(); + const remoteDirectory = URI.parse('vscode-remote://ssh-remote+host/workspace'); + const hostDirectory = URI.file('/workspace'); + const summary = { + resource: 'agent-session://copilotcli/remote-1', + provider: 'copilotcli', + title: 'Remote Chat', + status: SessionStatus.Idle, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + workingDirectories: [remoteDirectory.toString(), hostDirectory.toString()], + }; + let liveWorkingDirectories: readonly string[] | undefined; + disposables.add(client.onDidNotification(notification => { + if (notification.type === 'root/sessionAdded') { + liveWorkingDirectories = notification.summary.workingDirectories; + } + })); + transport.fireMessage({ + jsonrpc: '2.0', + method: 'root/sessionAdded', + params: { channel: ROOT_STATE_URI, summary }, + }); + + const resultPromise = client.listSessions(); + const sent = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', + id: sent.id, + result: { + items: [summary], + }, + }); + + const [session] = await resultPromise; + assert.deepStrictEqual({ + liveWorkingDirectories, + liveVisibleInWorkspace: liveWorkingDirectories?.some(directory => extUriBiasedIgnorePathCase.isEqualOrParent(URI.parse(directory), remoteDirectory)), + workingDirectories: session.workingDirectories?.map(uri => uri.toString()), + restoredVisibleInWorkspace: session.workingDirectories?.some(directory => extUriBiasedIgnorePathCase.isEqualOrParent(directory, remoteDirectory)), + }, { + liveWorkingDirectories: summary.workingDirectories, + liveVisibleInWorkspace: true, + workingDirectories: [ + remoteDirectory.toString(), + client.resourceUris.fromAgentHost(hostDirectory).toString(), + ], + restoredVisibleInWorkspace: true, + }); + }); + test('queues requests and notifications until a client transport initializes', async () => { const transport = disposables.add(new TestClientProtocolTransport()); const { client } = createClient(transport); From ac25654006e19a36f53fc7e0eb59bf1366fcc865 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 1 Sep 2026 18:39:57 +0200 Subject: [PATCH 05/41] sessions: Refresh chat status after background completion (#333804) Reacquire the owning session state when a chat turn completes so main and hidden chat statuses do not remain stale after the idle subscription expires. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/baseAgentHostSessionsProvider.ts | 12 +++++ .../localAgentHostSessionsProvider.test.ts | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 54f2cfbc117593..11e22c2a65432f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -4967,6 +4967,17 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement ); } + private _keepChatSessionStateAlive(chatChannel: string): void { + const parsedChat = parseChatUri(chatChannel); + if (!parsedChat) { + return; + } + const cached = this._sessionCache.get(AgentSession.id(parsedChat.session)); + if (cached) { + this._keepSessionStateAlive(cached.sessionId); + } + } + /** * Lazily acquire a session-state subscription for `sessionId` so that * `_runningSessionConfigs` is seeded from the AHP `SessionState.config` @@ -5662,6 +5673,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } if (e.action.type === ActionType.ChatTurnComplete && isChatAction(e.action)) { + this._keepChatSessionStateAlive(e.channel); this._refreshSessions(); } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { this._handleTitleChanged(e.channel, e.action.title); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index fbcba7d4676a6b..bcb50b161297d6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -5532,6 +5532,52 @@ suite('LocalAgentHostSessionsProvider', () => { defaultChatTitle: 'Renamed Default', }); }); + + test('turn completion refreshes main chat status after the session-state subscription expires', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-complete'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-complete').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const subagentChat = buildSubagentChatUri(sessionUri, 'tc-1'); + const stateWithStatus = (status: ProtocolSessionStatus): SessionState => makeState([ + makeChatSummary(defaultChat, '', status), + { + ...makeChatSummary(subagentChat, 'Reviewer', status), + origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tc-1' }, + }, + ], { defaultChat }); + + agentHost.setSessionState('multi-complete', 'copilotcli', stateWithStatus(ProtocolSessionStatus.InProgress)); + await timeout(31_000); + agentHost.setSessionState('multi-complete', 'copilotcli', stateWithStatus(ProtocolSessionStatus.Idle)); + const staleStatus = session.mainChat.get().status.get(); + + agentHost.fireAction({ + channel: subagentChat, + action: { + type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: 1000, + }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + await timeout(0); + + assert.deepStrictEqual({ + staleStatus, + updatedStatus: session.mainChat.get().status.get(), + subagentStatus: session.chats.get().find(chat => chat.origin?.kind === ChatOriginKind.Tool)?.status.get(), + subscribeCount: agentHost.sessionSubscribeCounts.get(sessionUri), + unsubscribeCount: agentHost.sessionUnsubscribeCounts.get(sessionUri), + }, { + staleStatus: SessionStatus.InProgress, + updatedStatus: SessionStatus.Completed, + subagentStatus: SessionStatus.Completed, + subscribeCount: 2, + unsubscribeCount: 1, + }); + })); }); // ---- Title change from server ------- From 35e20f9dcd9a5c16f9cda64be1a1db0788922d69 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 1 Sep 2026 12:50:20 -0400 Subject: [PATCH 06/41] chat: remove duplicate spawned session links (#333645) --- .../node/shared/artifactServerTools.ts | 9 ++- .../test/node/artifactServerTools.test.ts | 55 ++++++++++++++++++- .../chatResponseAccessibleView.ts | 2 + .../chatSessionCreatedResultSubPart.ts | 20 ++++--- .../chat/browser/widget/chatListRenderer.ts | 42 +++++++++++++- .../chatResponseAccessibleView.test.ts | 12 +++- .../chatToolProgressPart.test.ts | 18 +++--- .../browser/widget/chatListRenderer.test.ts | 26 +++++++++ 8 files changed, 161 insertions(+), 23 deletions(-) diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index 15eb3596c824c7..d8900bc782c6c4 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME } from '../../common/openSessionLink.js'; import { ArtifactServerToolName, LEGACY_ARTIFACT_SERVER_TOOL_NAMES } from '../../common/serverToolNames.js'; import { parseSessionArtifactInput, SessionArtifactCollection } from '../../common/sessionArtifactCollection.js'; import { readSessionArtifacts, SESSION_ARTIFACT_TYPES, SessionArtifactType, withSessionArtifacts, type ISessionArtifact } from '../../common/sessionArtifacts.js'; @@ -48,7 +50,7 @@ export const artifactServerToolDefinitions: ToolDefinition[] = [ { name: ArtifactServerToolName.AddArtifactOrReference, title: 'Add Artifact or Reference', - description: 'Record an artifact or a reference so it is surfaced next to the chat input. An artifact is something this session produced that is not just an ordinary workspace edit: a pull request or issue it opened, a plan or report file it wrote outside the workspace, or another side effect of its work. A reference is something the session did not produce but the user should look at because of this task: the pull request or commit that introduced a bug, an issue it investigated, or a website worth reading. Set `isArtifact` accordingly. Do not record routine files you merely edited.', + description: 'Record an artifact or a reference so it is surfaced next to the chat input. An artifact is something this session produced that is not just an ordinary workspace edit: a pull request or issue it opened, a plan or report file it wrote outside the workspace, or another side effect of its work. A reference is something the session did not produce but the user should look at because of this task: the pull request or commit that introduced a bug, an issue it investigated, or a website worth reading. Set `isArtifact` accordingly. Do not record routine files you merely edited or sessions and chats created with session-management tools.', inputSchema: addArtifactInputSchema, annotations: { readOnlyHint: false }, }, @@ -164,6 +166,9 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce switch (toolName) { case ArtifactServerToolName.AddArtifactOrReference: { const input = parseSessionArtifactInput(rawArgs, ArtifactServerToolName.AddArtifactOrReference); + if (input.uri && URI.parse(input.uri).scheme === AGENT_HOST_SESSION_LINK_SCHEME) { + throw new Error(`Invalid ${ArtifactServerToolName.AddArtifactOrReference} input: sessions and chats created with session-management tools must not be recorded as artifacts or references.`); + } const result = artifacts.read().add(input, generateUuid); if (!result.added) { return `Already recorded: ${describeArtifact(result.artifact)}`; @@ -201,4 +206,4 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce * The instruction appended to every agent's host instructions while the * artifact tools are enabled. */ -export const ARTIFACT_TOOLS_INSTRUCTION = `Record the notable results of your work with \`${ArtifactServerToolName.AddArtifactOrReference}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits) so they are surfaced next to the chat input. Pass \`isArtifact: true\` for an artifact — something this session produced beyond ordinary workspace edits, such as a pull request or issue you opened, a plan or report file you wrote outside the workspace, or another side effect of your work. Pass \`isArtifact: false\` for a reference — something you did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, an issue you investigated, or a website worth reading. Record each one once, and do not record routine files you merely edited or commits you create unless the user asks for them.`; +export const ARTIFACT_TOOLS_INSTRUCTION = `Record the notable results of your work with \`${ArtifactServerToolName.AddArtifactOrReference}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits) so they are surfaced next to the chat input. Pass \`isArtifact: true\` for an artifact — something this session produced beyond ordinary workspace edits, such as a pull request or issue you opened, a plan or report file you wrote outside the workspace, or another side effect of your work. Pass \`isArtifact: false\` for a reference — something you did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, an issue you investigated, or a website worth reading. Record each one once, and do not record routine files you merely edited, commits you create unless the user asks for them, or sessions and chats created with session-management tools.`; diff --git a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts index 3929bbd602e834..3a48e0270d5a7c 100644 --- a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts @@ -4,13 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { getErrorMessage } from '../../../../base/common/errors.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; import { ArtifactServerToolName } from '../../common/serverToolNames.js'; -import { artifactServerToolDefinitions, createArtifactServerToolGroup } from '../../node/shared/artifactServerTools.js'; +import { buildDefaultChatUri, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { ARTIFACT_TOOLS_INSTRUCTION, artifactServerToolDefinitions, createArtifactServerToolGroup } from '../../node/shared/artifactServerTools.js'; import { getServerToolDisplay } from '../../node/shared/serverToolGroups.js'; suite('Artifact Server Tools', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); const group = createArtifactServerToolGroup(); const display = (toolName: string, args: unknown, result?: { text: string; success: boolean }) => group.getDisplay?.(toolName, args, result); @@ -38,6 +42,53 @@ suite('Artifact Server Tools', () => { }); }); + test('excludes session-management results from artifacts', () => { + const addDefinition = artifactServerToolDefinitions.find(definition => definition.name === ArtifactServerToolName.AddArtifactOrReference); + + assert.deepStrictEqual({ + definition: addDefinition?.description?.includes('sessions and chats created with session-management tools'), + instruction: ARTIFACT_TOOLS_INSTRUCTION.includes('sessions and chats created with session-management tools'), + }, { + definition: true, + instruction: true, + }); + }); + + test('rejects session-management links during execution', async () => { + const sessionUri = 'copilot:/caller'; + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: sessionUri, + provider: 'copilot', + title: 'Caller', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + let persisted = false; + const group = createArtifactServerToolGroup({ + isEnabled: () => true, + persist: () => persisted = true, + }); + + let errorMessage: string | undefined; + try { + await group.execute(stateManager, { sessionUri, chatUri: buildDefaultChatUri(sessionUri), turnId: 'turn-1' }, ArtifactServerToolName.AddArtifactOrReference, { + type: 'resource', + label: 'Spawned session', + isArtifact: true, + uri: 'agent-host-session://copilot/spawned', + }); + } catch (error) { + errorMessage = getErrorMessage(error); + } + + assert.deepStrictEqual({ errorMessage, persisted }, { + errorMessage: 'Invalid add_artifact_or_reference input: sessions and chats created with session-management tools must not be recorded as artifacts or references.', + persisted: false, + }); + }); + test('names what a completed removal actually removed', () => { const removed = (text: string) => display(ArtifactServerToolName.RemoveArtifactOrReference, { id: 'id-1' }, { text, success: true })?.pastTenseMessage; diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index aef97094b8b40a..1df662c1deda0d 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -158,6 +158,8 @@ export function getToolSpecificDataDescription(toolSpecificData: ToolSpecificDat return toolSpecificData.operation === 'created' ? localize('automationConfigured.created', "Created an automation: {0}", toolSpecificData.automationName) : localize('automationConfigured.updated', "Edited an automation: {0}", toolSpecificData.automationName); + case 'sessionCreated': + return toolSpecificData.fullTitle ?? toolSpecificData.label; default: return ''; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts index 5586213e2d7813..525b1ccb89e3d7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSessionCreatedResultSubPart.ts @@ -7,13 +7,14 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { getDefaultHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { autorun } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; -import { ILinkPresentationService } from '../../../../../../../platform/dataChannel/common/dataChannel.js'; +import { ILinkPresentation, ILinkPresentationService } from '../../../../../../../platform/dataChannel/common/dataChannel.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { IChatSessionCreatedData, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IChatCodeBlockInfo } from '../../../chat.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; +import { ChatRichLink } from '../chatRichLink.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; import '../media/chatSessionCreatedResult.css'; @@ -39,7 +40,13 @@ export class ChatSessionCreatedResultSubPart extends BaseChatToolInvocationSubPa super(toolInvocation); this.domNode = dom.$('.chat-open-session-result'); - const link = dom.append(this.domNode, dom.$('a.monaco-link', { href: this.data.openLink }, this.data.label)); + const link = dom.append(this.domNode, dom.$('a.monaco-link', { href: this.data.openLink })); + const richLink = this._register(ChatRichLink.mount(link, dom.$('span', undefined, this.data.label))); + const fallbackPresentation: ILinkPresentation = { + kind: this.data.isChat ? 'chat' : 'session', + title: this.data.fullTitle ?? this.data.label, + }; + richLink.update(fallbackPresentation); const hover = this._register(hoverService.setupManagedHover( getDefaultHoverDelegate('mouse'), link, @@ -49,18 +56,15 @@ export class ChatSessionCreatedResultSubPart extends BaseChatToolInvocationSubPa dom.EventHelper.stop(event, true); void this.openerService.open(URI.parse(this.data.openLink), { fromUserGesture: true, allowContributedOpeners: true }); })); - const resource = URI.parse(this.data.openLink); const rule = linkPresentationService.getLinkPresentationRule(resource); const watcher = rule ? linkPresentationService.createLinkPresentationWatcher(rule.id, resource) : undefined; if (watcher) { this._register(watcher); this._register(autorun(reader => { - const presentation = watcher.presentation.read(reader); - const fullTitle = presentation?.title ?? this.data.fullTitle ?? this.data.label; - const label = fullTitle.length > 60 ? `${fullTitle.slice(0, 57)}…` : fullTitle; - link.textContent = label; - hover.update(fullTitle); + const presentation = watcher.presentation.read(reader) ?? fallbackPresentation; + richLink.update(presentation); + hover.update(presentation.tooltip ?? presentation.title ?? this.data.fullTitle ?? this.data.label); })); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 1a565e315e8048..2674e66f786ac9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -25,6 +25,7 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, dispose, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; +import { rewriteMarkdownLinks } from '../../../../../base/common/markdownLinks.js'; import { ScrollEvent } from '../../../../../base/common/scrollable.js'; import { FileAccess, Schemas } from '../../../../../base/common/network.js'; import { clamp, formatTokenCount } from '../../../../../base/common/numbers.js'; @@ -280,6 +281,36 @@ function isResponseOutcomeTool(part: IChatRendererContent): boolean { && (part.toolSpecificData?.kind === 'sessionCreated' || part.toolSpecificData?.kind === 'generatedImage'); } +function getSessionCreatedOutcomeLink(part: IChatRendererContent): string | undefined { + return (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && part.toolSpecificData?.kind === 'sessionCreated' + ? part.toolSpecificData.openLink + : undefined; +} + +function getFinalResponseLinkTargets(content: ReadonlyArray): ReadonlySet { + const targets = new Set(); + const finalResponseStartIndex = getFinalResponseStartIndex(content); + if (finalResponseStartIndex === undefined) { + return targets; + } + + for (let index = finalResponseStartIndex; index < content.length; index++) { + const part = content[index]; + if (part.kind !== 'markdownContent') { + break; + } + rewriteMarkdownLinks(part.content.value, { + rewriteLink: token => { + if (token.type === 'link') { + targets.add(token.href); + } + return undefined; + } + }); + } + return targets; +} + export function getFinalResponseStartIndexAfterMovingResponseOutcomeTools(content: ReadonlyArray): number | undefined { const finalResponseStartIndex = getFinalResponseStartIndex(content); if (finalResponseStartIndex === undefined) { @@ -304,6 +335,15 @@ export function moveResponseOutcomeToolsAfterFinalResponse(content: ReadonlyArra if (outcomeTools.length === 0) { return [...content]; } + const responseLinkTargets = outcomeTools.some(part => getSessionCreatedOutcomeLink(part) !== undefined) + ? getFinalResponseLinkTargets(content) + : undefined; + const uniqueOutcomeTools = responseLinkTargets + ? outcomeTools.filter(part => { + const openLink = getSessionCreatedOutcomeLink(part); + return openLink === undefined || !responseLinkTargets.has(openLink); + }) + : outcomeTools; const finalResponseStartIndex = getFinalResponseStartIndexAfterMovingResponseOutcomeTools(content); if (finalResponseStartIndex === undefined) { @@ -315,7 +355,7 @@ export function moveResponseOutcomeToolsAfterFinalResponse(content: ReadonlyArra while (reordered[insertionIndex]?.kind === 'markdownContent') { insertionIndex++; } - reordered.splice(insertionIndex, 0, ...outcomeTools); + reordered.splice(insertionIndex, 0, ...uniqueOutcomeTools); return reordered; } diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index b24af21b51759d..49e952a108d043 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -14,7 +14,7 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ChatResponseAccessibleView, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; -import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; +import { IChatExtensionsContent, IChatPullRequestContent, IChatSessionCreatedData, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; suite('ChatResponseAccessibleView', () => { @@ -228,6 +228,16 @@ suite('ChatResponseAccessibleView', () => { 'Edited an automation: Morning review', ]); }); + + test('describes a created session using its full title when available', () => { + const sessionData: IChatSessionCreatedData = { + kind: 'sessionCreated', + openLink: 'agent-host-session://local/session', + label: 'Implement issue', + fullTitle: 'Implement issue and validate the fix', + }; + assert.strictEqual(getToolSpecificDataDescription(sessionData), 'Implement issue and validate the fix'); + }); }); suite('getResultDetailsDescription', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts index 49ea2693f20f0e..14c5d178f1b32a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts @@ -327,7 +327,7 @@ suite('ChatToolProgressSubPart', () => { assert.strictEqual(createInstanceStub.firstCall.args[0], ChatAutomationConfiguredResultSubPart); }); - test('renders a created session as a plain title link', () => { + test('renders a created session as a rich session link', () => { const updateHover = sinon.spy(); const setupManagedHoverStub = sinon.stub(mockHoverService, 'setupManagedHover').returns({ dispose() { }, @@ -357,17 +357,17 @@ suite('ChatToolProgressSubPart', () => { const link = part.domNode.querySelector('a.monaco-link'); assert.deepStrictEqual({ - text: link?.textContent, + title: link?.querySelector('.chat-rich-link-title')?.textContent, href: link?.getAttribute('href'), hoverTitle: updateHover.lastCall.args[0], - role: link?.getAttribute('role'), - hasButton: !!part.domNode.querySelector('.monaco-button'), + kind: link?.dataset.chatRichLinkKind, + hasRichLink: link?.classList.contains('chat-rich-link'), }, { - text: `${runningTitle.slice(0, 57)}…`, + title: runningTitle, href: 'agent-host-session://copilot/task-a', hoverTitle: runningTitle, - role: null, - hasButton: false, + kind: 'session', + hasRichLink: true, }); sessionLinkPresentation.set({ @@ -376,10 +376,10 @@ suite('ChatToolProgressSubPart', () => { status: { kind: 'success', label: 'Completed' }, }, undefined); assert.deepStrictEqual({ - text: link?.textContent, + title: link?.querySelector('.chat-rich-link-title')?.textContent, hoverTitle: updateHover.lastCall.args[0], }, { - text: 'Finished weather session', + title: 'Finished weather session', hoverTitle: 'Finished weather session', }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 9801d54025729a..88976f16e8f936 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -210,6 +210,32 @@ suite('ChatListRenderer', () => { }); }); + test('deduplicates a created-session link echoed in the final response', () => { + const tool: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'create-session', + toolId: 'create_session', + invocationMessage: 'Creating session...', + originMessage: undefined, + pastTenseMessage: 'Created session', + isComplete: true, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + presentation: undefined, + source: ToolDataSource.Internal, + toolSpecificData: { + kind: 'sessionCreated', + openLink: 'agent-host-session://local/session', + label: 'Implement issue', + }, + }; + const finalResponse = { + kind: 'markdownContent', + content: new MarkdownString('Done: [Implement issue](agent-host-session://local/session)'), + } as const; + + assert.deepStrictEqual(moveResponseOutcomeToolsAfterFinalResponse([tool, finalResponse]), [finalResponse]); + }); + test('leaves created-session tools in place when there is no final response', () => { const tool: IChatToolInvocationSerialized = { kind: 'toolInvocationSerialized', From 15463110a65b31e321026321065cdb02269618a8 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:07:08 -0700 Subject: [PATCH 07/41] Fix agents window attachment chips to match chat attachment behavior (#333304) * sessions: fix empty-state attachment pills Match the established chat attachment layout by leading with the remove action and rendering themed resource, provider, or generic fallback icons. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 878234d1-4d87-4726-add4-40b417070bdb * sessions: address attachment review feedback Keep open and remove actions as sibling controls, honor themed provider resource icons, and update the intentional component screenshot baselines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ace2046a-b07a-4f5b-a1c4-3c7fb341ccc3 * sessions: refine attachment icon behavior Use compact glyphs, preserve focused controls across color-theme changes, and keep pasted-text fallbacks compatible with the None icon theme. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ace2046a-b07a-4f5b-a1c4-3c7fb341ccc3 * sessions: align attachment tests after rebase Update the merged DOM expectation and keep Node.contains null-safe against optional query results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ace2046a-b07a-4f5b-a1c4-3c7fb341ccc3 * test: update attachment screenshot baselines Accept the intentional dark and light fixture changes after compact attachment icons and leading removal controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ace2046a-b07a-4f5b-a1c4-3c7fb341ccc3 --------- Copilot-Session: 878234d1-4d87-4726-add4-40b417070bdb Copilot-Session: ace2046a-b07a-4f5b-a1c4-3c7fb341ccc3 --- .../contrib/chat/browser/media/chatInput.css | 11 +- .../chat/browser/newChatContextAttachments.ts | 70 +++-- .../chat/test/browser/newChatInput.test.ts | 263 +++++++++++++++++- .../blocks-ci-screenshots.md | 4 +- 4 files changed, 314 insertions(+), 34 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 817cf8b88574b3..1427bcea7824c5 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -617,10 +617,10 @@ .sessions-chat-attachment-image { width: 13px; height: 13px; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); object-fit: cover; flex-shrink: 0; - margin: 0 3px; + margin: 0 var(--vscode-spacing-size40); } .sessions-chat-attachment-pill { @@ -674,7 +674,8 @@ white-space: nowrap; } -.sessions-chat-attachment-pill .codicon { +.monaco-workbench .sessions-chat-attachment-pill .sessions-chat-attachment-content .codicon, +.monaco-workbench .sessions-chat-attachment-pill .sessions-chat-attachment-open .codicon { font-size: var(--vscode-codiconFontSize-compact); flex-shrink: 0; padding: 0 var(--vscode-spacing-size20); @@ -719,14 +720,14 @@ cursor: pointer; flex-shrink: 0; color: var(--vscode-descriptionForeground); - margin-left: var(--vscode-spacing-size20); + margin-right: var(--vscode-spacing-size20); } .sessions-chat-attachment-pill.openable:hover { background-color: var(--vscode-toolbar-hoverBackground); } -.sessions-chat-attachment-pill .sessions-chat-attachment-remove .codicon { +.monaco-workbench .sessions-chat-attachment-pill .sessions-chat-attachment-remove .codicon { font-size: var(--vscode-codiconFontSize-compact, 12px); padding: 0; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index d8e467295918c7..58f9cebbf44e86 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -5,13 +5,15 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { isDark } from '../../../../platform/theme/common/theme.js'; +import { FileThemeIcon, FolderThemeIcon, IThemeService } from '../../../../platform/theme/common/themeService.js'; import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; @@ -34,12 +36,13 @@ import { Schemas } from '../../../../base/common/network.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; -import { IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, isPastedTextArtifact, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, isPastedTextArtifact, isStringVariableEntry, OmittedState, resolveChatContextIcon } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { isLocation } from '../../../../editor/common/languages.js'; import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; import { createImageHoverContent, openPastedTextArtifact } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX } from '../common/newChatContextIds.js'; /** @@ -71,6 +74,7 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt private readonly _attachedContext: IChatRequestVariableEntry[] = []; private _container: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _fileIconThemeScope = this._register(new MutableDisposable()); private readonly _onDidChangeContext = this._register(new Emitter()); readonly onDidChangeContext = this._onDidChangeContext.event; @@ -102,15 +106,18 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, + @IThemeService private readonly themeService: IThemeService, ) { super(); this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + this._register(this.themeService.onDidFileIconThemeChange(() => this._updateRendering())); } // --- Rendering --- renderAttachedContext(container: HTMLElement): void { this._container = container; + this._fileIconThemeScope.value = createFileIconThemableTreeContainerScope(container, this.themeService); this._updateRendering(); } @@ -130,10 +137,21 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt } this._container.style.display = ''; - this._container.classList.add('show-file-icons'); for (const entry of visibleAttachments) { const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill')); + const removeButton = dom.append(pill, dom.$('button.sessions-chat-attachment-remove')); + removeButton.type = 'button'; + removeButton.title = localize('removeAttachment', "Remove"); + removeButton.setAttribute('aria-label', localize('removeNamedAttachment', "Remove {0}", entry.name)); + const removeIcon = dom.append(removeButton, renderIcon(Codicon.closeCompact)); + removeIcon.setAttribute('aria-hidden', 'true'); + this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.KEY_DOWN, e => e.stopPropagation())); + this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, e => { + e.stopPropagation(); + this.removeAttachment(entry.id); + })); + const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; const githubContextResource = entry.id.startsWith(GITHUB_CONTEXT_ID_PREFIX) ? URI.parse(entry.id.slice(GITHUB_CONTEXT_ID_PREFIX.length)) @@ -152,7 +170,7 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt content = dom.append(pill, dom.$('span.sessions-chat-attachment-content')); } if (entry.kind === 'image') { - const icon = dom.append(content, renderIcon(Codicon.fileMedia)); + const icon = dom.append(content, renderIcon(Codicon.fileMediaCompact)); dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); if (imageData) { // Swap the generic icon for a thumbnail once the shared helper @@ -165,7 +183,7 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt this._renderDisposables.add(preview.disposable); } } else if (entry.id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX)) { - const icon = dom.append(content, renderIcon(Codicon.repo)); + const icon = dom.append(content, renderIcon(Codicon.repoCompact)); icon.setAttribute('aria-hidden', 'true'); dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); } else if (entry.icon) { @@ -176,20 +194,42 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt } dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); } else { - const label = this._resourceLabels.create(content, { supportIcons: true }); + const label = this._resourceLabels.create(content); this._renderDisposables.add(label); - if (resource) { + if (resource && (entry.kind === 'file' || entry.kind === 'directory')) { + const fileIconTheme = this.themeService.getFileIconTheme(); label.setFile(resource, { fileKind: entry.kind === 'directory' ? FileKind.FOLDER : FileKind.FILE, hidePath: true, + icon: entry.kind === 'directory' + ? (!fileIconTheme.hasFolderIcons ? FolderThemeIcon : undefined) + : (!fileIconTheme.hasFileIcons ? FileThemeIcon : undefined), }); } else if (isPastedTextArtifact(entry)) { // Matches the workbench paste pill: a file icon for the artifact's // language, and how much text it stands in for. - label.setLabel(entry.fileName, undefined, { extraClasses: ['file-icon', `${entry.language}-lang-file-icon`] }); + label.setLabel(entry.fileName, undefined, this.themeService.getFileIconTheme().hasFileIcons + ? { extraClasses: ['file-icon', `${entry.language}-lang-file-icon`] } + : { extraClasses: getIconClasses(this.modelService, this.languageService, undefined, FileKind.FILE, FileThemeIcon) }); dom.append(content, dom.$('span.sessions-chat-attachment-info', undefined, localize('pastedLines', "Pasted {0}", entry.pastedLines))); } else { - label.setLabel(entry.name); + const iconPath = (isStringVariableEntry(entry) || entry.kind === 'generic') ? entry.iconPath : undefined; + const attachmentLabel = entry.fullName ?? entry.name; + const updateLabel = () => { + if (isStringVariableEntry(entry) && ThemeIcon.isThemeIcon(iconPath) && (ThemeIcon.isFile(iconPath) || ThemeIcon.isFolder(iconPath)) && entry.resourceUri) { + const fileKind = ThemeIcon.isFolder(iconPath) ? FileKind.FOLDER : FileKind.FILE; + label.setLabel(attachmentLabel, undefined, { extraClasses: getIconClasses(this.modelService, this.languageService, entry.resourceUri, fileKind) }); + } else { + const icon = iconPath + ? resolveChatContextIcon(iconPath, isDark(this.themeService.getColorTheme().type)) + : entry.icon ?? Codicon.attachCompact; + label.setLabel(attachmentLabel, undefined, { iconPath: icon }); + } + }; + updateLabel(); + if (iconPath && !ThemeIcon.isThemeIcon(iconPath) && !URI.isUri(iconPath)) { + this._renderDisposables.add(this.themeService.onDidColorThemeChange(updateLabel)); + } } } @@ -212,18 +252,6 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt await this.instantiationService.invokeFunction(openPastedTextArtifact, entry); })); } - - const removeButton = dom.append(pill, dom.$('button.sessions-chat-attachment-remove')); - removeButton.type = 'button'; - removeButton.title = localize('removeAttachment', "Remove"); - removeButton.setAttribute('aria-label', localize('removeNamedAttachment', "Remove {0}", entry.name)); - const removeIcon = dom.append(removeButton, renderIcon(Codicon.closeCompact)); - removeIcon.setAttribute('aria-hidden', 'true'); - this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.KEY_DOWN, e => e.stopPropagation())); - this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, (e) => { - e.stopPropagation(); - this.removeAttachment(entry.id); - })); } } diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index 0a8deaa5de612f..a0e8a424403bfb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -4,17 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { IIconLabelValueOptions } from '../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { IResolvedTextEditorModel } from '../../../../../editor/common/services/resolverService.js'; +import { FileKind } from '../../../../../platform/files/common/files.js'; +import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; +import { FileThemeIcon, FolderThemeIcon } from '../../../../../platform/theme/common/themeService.js'; +import { IFileLabelOptions } from '../../../../../workbench/browser/labels.js'; import { hasSendableNewChatContent, NewChatInputWidget } from '../../browser/newChatInput.js'; -import { IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatPasteAttachmentMetadata, IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { NewChatContextAttachments } from '../../browser/newChatContextAttachments.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; @@ -95,14 +102,25 @@ interface IAttachmentRenderingHarness { readonly _renderDisposables: DisposableStore; readonly _resourceLabels: { clear(): void; - create(container: HTMLElement, options: { supportIcons: boolean }): IDisposable & { - setLabel(label: string): void; - setFile(resource: URI, options: object): void; + create(container: HTMLElement): IDisposable & { + setLabel(label: string, description?: string, options?: IIconLabelValueOptions): void; + setFile(resource: URI, options?: IFileLabelOptions): void; }; }; readonly openerService: { open(resource: URI): Promise; }; + readonly themeService?: { + getFileIconTheme(): { hasFileIcons: boolean; hasFolderIcons: boolean }; + getColorTheme(): { type: ColorScheme }; + readonly onDidColorThemeChange: Event; + }; + readonly modelService?: { + getModel(): null; + }; + readonly languageService?: { + guessLanguageIdByFilepathOrFirstLine(): string; + }; removeAttachment(id: string): void; } @@ -486,6 +504,233 @@ suite('NewChatInputWidget', () => { } }); + test('renders leading removal and compact attachment icons', () => { + const container = document.createElement('div'); + const entries: IChatRequestVariableEntry[] = [ + { + kind: 'file', + id: 'file', + name: 'README.md', + value: URI.file('/workspace/README.md'), + }, + { + kind: 'directory', + id: 'directory', + name: 'spritesheet', + value: URI.file('/workspace/spritesheet'), + }, + { + kind: 'generic', + id: 'unknown', + name: 'Unknown context', + value: 'unknown', + }, + { + kind: 'string', + id: 'themed-file', + name: 'Themed file', + value: 'themed-file', + uri: URI.parse('vscode://context/themed-file'), + resourceUri: URI.file('/workspace/src/index.ts'), + iconPath: FileThemeIcon, + handle: 1, + }, + { + kind: 'image', + id: 'image', + name: 'image.png', + value: URI.file('/workspace/image.png'), + }, + ]; + const labels: { label: string; icon?: string; extraClasses?: readonly string[] }[] = []; + const files: { resource: string; fileKind?: FileKind; icon?: string }[] = []; + const renderDisposables = disposables.add(new DisposableStore()); + updateAttachmentRendering.call({ + _container: container, + _attachedContext: entries, + _renderDisposables: renderDisposables, + _resourceLabels: { + clear: () => { }, + create: content => { + const labelElement = document.createElement('span'); + labelElement.className = 'resource-label'; + content.appendChild(labelElement); + return { + dispose: () => { }, + setLabel: (label, _description, options) => labels.push({ + label, + icon: ThemeIcon.isThemeIcon(options?.iconPath) ? options.iconPath.id : options?.iconPath?.toString(), + extraClasses: options?.extraClasses, + }), + setFile: (resource, options) => files.push({ + resource: resource.path, + fileKind: options?.fileKind, + icon: ThemeIcon.isThemeIcon(options?.icon) ? options.icon.id : options?.icon?.toString(), + }), + }; + }, + }, + openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: true, hasFolderIcons: false }), + getColorTheme: () => ({ type: ColorScheme.DARK }), + onDidColorThemeChange: Event.None, + }, + modelService: { + getModel: () => null, + }, + languageService: { + guessLanguageIdByFilepathOrFirstLine: () => 'typescript', + }, + removeAttachment: () => { }, + }); + const firstPill = container.querySelector('.sessions-chat-attachment-pill'); + const openButton = firstPill?.querySelector('.sessions-chat-attachment-open'); + const removeButton = firstPill?.querySelector('.sessions-chat-attachment-remove'); + + assert.deepStrictEqual({ + pillChildren: Array.from(firstPill?.children ?? []).map(child => child.className), + removeButtonNestedInOpenButton: openButton?.contains(removeButton ?? null), + hasCompactImageIcon: !!container.querySelector('.codicon-file-media-compact'), + files, + labels, + }, { + pillChildren: ['sessions-chat-attachment-remove', 'sessions-chat-attachment-open'], + removeButtonNestedInOpenButton: false, + hasCompactImageIcon: true, + files: [ + { resource: '/workspace/README.md', fileKind: FileKind.FILE, icon: undefined }, + { resource: '/workspace/spritesheet', fileKind: FileKind.FOLDER, icon: FolderThemeIcon.id }, + ], + labels: [ + { label: 'Unknown context', icon: Codicon.attachCompact.id, extraClasses: undefined }, + { + label: 'Themed file', + icon: undefined, + extraClasses: ['file-icon', 'src-name-dir-icon', 'index.ts-name-file-icon', 'name-file-icon', 'ts-ext-file-icon', 'ext-file-icon', 'typescript-lang-file-icon'], + }, + ], + }); + }); + + test('updates light and dark attachment icons without rebuilding controls', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const colorThemeEmitter = disposables.add(new Emitter()); + const lightIcon = URI.parse('test:/light.svg'); + const darkIcon = URI.parse('test:/dark.svg'); + let colorScheme = ColorScheme.DARK; + const icons: string[] = []; + const renderDisposables = disposables.add(new DisposableStore()); + updateAttachmentRendering.call({ + _container: container, + _attachedContext: [{ + kind: 'generic', + id: 'themed', + name: 'Themed context', + value: 'themed', + iconPath: { light: lightIcon, dark: darkIcon }, + }], + _renderDisposables: renderDisposables, + _resourceLabels: { + clear: () => { }, + create: content => { + content.appendChild(document.createElement('span')); + return { + dispose: () => { }, + setLabel: (_label, _description, options) => { + if (URI.isUri(options?.iconPath)) { + icons.push(options.iconPath.toString()); + } + }, + setFile: () => { }, + }; + }, + }, + openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: true, hasFolderIcons: true }), + getColorTheme: () => ({ type: colorScheme }), + onDidColorThemeChange: colorThemeEmitter.event, + }, + modelService: { + getModel: () => null, + }, + languageService: { + guessLanguageIdByFilepathOrFirstLine: () => 'typescript', + }, + removeAttachment: () => { }, + }); + const pill = container.querySelector('.sessions-chat-attachment-pill'); + const removeButton = container.querySelector('.sessions-chat-attachment-remove'); + removeButton?.focus(); + + colorScheme = ColorScheme.LIGHT; + colorThemeEmitter.fire(); + + assert.deepStrictEqual({ + icons, + samePill: container.querySelector('.sessions-chat-attachment-pill') === pill, + sameRemoveButton: container.querySelector('.sessions-chat-attachment-remove') === removeButton, + focusedElementPreserved: document.activeElement === removeButton, + }, { + icons: [darkIcon.toString(), lightIcon.toString()], + samePill: true, + sameRemoveButton: true, + focusedElementPreserved: true, + }); + container.remove(); + }); + + test('marks the pasted text fallback as a predefined file icon', () => { + const container = document.createElement('div'); + const entry = toPasteVariableEntry('Pasted text', 'const value = 1;', { + language: 'typescript', + fileName: 'pasted.ts', + pastedLines: '1 line', + _meta: { [ChatPasteAttachmentMetadata.TextArtifact]: true }, + }); + let labelOptions: IIconLabelValueOptions | undefined; + const renderDisposables = disposables.add(new DisposableStore()); + updateAttachmentRendering.call({ + _container: container, + _attachedContext: [entry], + _renderDisposables: renderDisposables, + _resourceLabels: { + clear: () => { }, + create: content => { + content.appendChild(document.createElement('span')); + return { + dispose: () => { }, + setLabel: (_label, _description, options) => labelOptions = options, + setFile: () => { }, + }; + }, + }, + openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: false, hasFolderIcons: false }), + getColorTheme: () => ({ type: ColorScheme.DARK }), + onDidColorThemeChange: Event.None, + }, + modelService: { + getModel: () => null, + }, + languageService: { + guessLanguageIdByFilepathOrFirstLine: () => 'typescript', + }, + removeAttachment: () => { }, + }); + + assert.deepStrictEqual({ + iconPath: labelOptions?.iconPath, + extraClasses: labelOptions?.extraClasses, + }, { + iconPath: undefined, + extraClasses: ['codicon-file', 'predefined-file-icon'], + }); + }); + test('renders additional folder and repository context as attachment pills', () => { const container = document.createElement('div'); const folder = URI.file('/workspace/docs'); @@ -518,6 +763,11 @@ suite('NewChatInputWidget', () => { }), }, openerService: { open: async () => true }, + themeService: { + getFileIconTheme: () => ({ hasFileIcons: true, hasFolderIcons: false }), + getColorTheme: () => ({ type: ColorScheme.DARK }), + onDidColorThemeChange: Event.None, + }, removeAttachment: () => { }, }); @@ -525,10 +775,11 @@ suite('NewChatInputWidget', () => { Array.from(container.querySelectorAll('.sessions-chat-attachment-pill')).map(pill => ({ text: pill.textContent, removeAriaLabel: pill.querySelector('.sessions-chat-attachment-remove')?.getAttribute('aria-label'), + hasCompactRepositoryIcon: !!pill.querySelector('.codicon-repo-compact'), })), [ - { text: 'docs', removeAriaLabel: 'Remove docs' }, - { text: 'microsoft/typescript', removeAriaLabel: 'Remove microsoft/typescript' }, + { text: 'docs', removeAriaLabel: 'Remove docs', hasCompactRepositoryIcon: false }, + { text: 'microsoft/typescript', removeAriaLabel: 'Remove microsoft/typescript', hasCompactRepositoryIcon: true }, ], ); }); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 8687b470258d2f..31be6b91a1ddd1 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -181,10 +181,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/1d76cd2c4bda9bed2203215ccd84ef0644895a8eddd5efc5934520dce3f5fce8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f448cad0bf3f94c930bd1dbd3cc76f41100f375adaa02c683f8dbdd53caf8b3e) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe415859b934c657863844d2bfbf30da4ae84a2b167301ba2517d317588c6ed8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3283252df24d7dc46007ec8090d8db038b8b238be9793e1a76ce495862433337) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5e62a510e536d64b4d76dc940a3ad0b744caa7c3a58111492be9f20ea8db801) From 39767fdcf6220e2913489c3ede13dd7f77535237 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 1 Sep 2026 10:36:48 -0700 Subject: [PATCH 08/41] chat: remove deprecated agent debug log setting (#333650) Remove deprecated agent debug log setting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 10 ---------- extensions/copilot/package.nls.json | 2 -- .../configuration/common/configurationService.ts | 4 +--- .../contrib/chat/common/chatService/chatServiceImpl.ts | 1 - .../contrib/chat/common/promptSyntax/promptTypes.ts | 5 ----- 5 files changed, 1 insertion(+), 21 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5eba41442252af..c83a04b76d13dc 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -5242,16 +5242,6 @@ "onExp" ] }, - "github.copilot.chat.agentDebugLog.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%github.copilot.config.chat.agentDebugLog.enabled%", - "deprecationMessage": "%github.copilot.config.chat.agentDebugLog.enabled.deprecated%", - "tags": [ - "advanced", - "experimental" - ] - }, "github.copilot.chat.agentDebugLog.fileLogging.enabled": { "type": "boolean", "default": false, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 0839456250b8a3..8165b301befd75 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -390,8 +390,6 @@ "github.copilot.config.inlineChat.reasoningEffort": "Controls the reasoning effort level for inline chat requests. Lower values result in faster responses with fewer reasoning tokens. Supported values depend on the model.", "github.copilot.config.inlineChat.enableThinking": "Controls whether thinking/reasoning is enabled for inline chat requests. When disabled, reasoning summaries are suppressed for faster responses.", "github.copilot.config.debug.requestLogger.maxEntries": "Maximum number of entries to keep in the request logger for debugging purposes.", - "github.copilot.config.chat.agentDebugLog.enabled": "Deprecated: use `github.copilot.chat.agentDebugLog.fileLogging.enabled` instead.", - "github.copilot.config.chat.agentDebugLog.enabled.deprecated": "This setting has been merged into `github.copilot.chat.agentDebugLog.fileLogging.enabled`. Please use this setting instead.", "github.copilot.config.chat.agentDebugLog.fileLogging.enabled": "Enable agent debug logging: write chat debug events (tool calls, LLM requests, token usage, errors) to JSONL files for the debug panel and troubleshoot skill. Requires window reload to take effect.", "github.copilot.config.chat.agentDebugLog.fileLogging.flushIntervalMs": "How often (in milliseconds) buffered debug log entries are flushed to disk. Lower values provide more up-to-date logs at the cost of more frequent disk writes.", "github.copilot.config.chat.agentDebugLog.fileLogging.maxRetainedSessionLogs": "Maximum number of chat debug session log directories to retain on disk. Each chat session produces one directory. Older session logs are automatically deleted when this limit is exceeded.", diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 9cd52e957eca50..20c28c726c5009 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -846,9 +846,7 @@ export namespace ConfigKey { /** Simulate GitHub authentication failures for testing. Can't be TeamInternal because we lose these flags as part of testing. */ export const DebugGitHubAuthFailWith = defineSetting<'NotAuthorized' | 'RequestFailed' | 'ParseFailed' | 'HTTP401' | 'RateLimited' | 'GitHubLoginFailed' | null>('chat.debug.githubAuthFailWith', ConfigType.Simple, null); - // Agent debug logging settings — fileLogging.enabled is the canonical toggle - /** @deprecated Use ChatDebugFileLogging instead. Kept during experiment transition. */ - export const AgentDebugLogEnabled = defineAndMigrateExpSetting('agentDebugLog.enabled', 'chat.agentDebugLog.enabled', false); + // Agent debug logging settings export const ChatDebugFileLogging = defineAndMigrateExpSetting('chat.chatDebug.fileLogging.enabled', 'chat.agentDebugLog.fileLogging.enabled', false); export const ChatDebugFileLoggingFlushInterval = defineAndMigrateSetting('chat.chatDebug.fileLogging.flushIntervalMs', 'chat.agentDebugLog.fileLogging.flushIntervalMs', 4000); export const ChatDebugFileLoggingMaxRetainedSessionLogs = defineSetting('chat.agentDebugLog.fileLogging.maxRetainedSessionLogs', ConfigType.ExperimentBased, 50); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index f7bfc3d9d23466..209b9cbcd53f3a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1530,7 +1530,6 @@ export class ChatService extends Disposable implements IChatService { let detectedCommand: IChatAgentCommand | undefined; // Gate /troubleshoot and the troubleshoot skill behind the file logging flag. - // agentDebugLog.enabled is deprecated; only fileLogging.enabled is authoritative. { const fileLoggingEnabled = this.configurationService.getValue(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING); if (!fileLoggingEnabled) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index c94c7f7fc7fc13..528619edf8bafa 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -41,11 +41,6 @@ export const SKILL_LANGUAGE_ID = 'skill'; */ export const ALL_PROMPTS_LANGUAGE_SELECTOR: LanguageSelector = [PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID, SKILL_LANGUAGE_ID]; -/** - * Configuration key for enabling the agent debug log feature. - */ -export const AGENT_DEBUG_LOG_ENABLED_SETTING = 'github.copilot.chat.agentDebugLog.enabled'; - /** * Configuration key for enabling file logging for the agent debug log. */ From 1f02758a9d69e16d15384b1247ea51e17aab2b3f Mon Sep 17 00:00:00 2001 From: VS Code PR Bot Date: Tue, 1 Sep 2026 10:41:25 -0700 Subject: [PATCH 09/41] fix: cancel pending single-pane docked-tab reconciles on dispose (fixes #333537) (#333541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cancel pending single-pane docked-tab reconciles on dispose (fixes #333537) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: guard all coordinator sequencer tasks against disposal + add regression test Route every SinglePaneDockedTabsCoordinator sequencer task through a disposal-aware _queue() helper so a task still queued or resumed after teardown never opens editors via the now-disposed instantiation service. Adds a regression test covering a reconcile stalled mid-open across dispose. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review on desktopSessionLayoutController.test.ts:2984 — clarify test name that only later editor opens are prevented Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review on singlePaneDockedTabsCoordinator.ts — trim dispose comment and add disposal checkpoint in _reconcileForeignChangesEditors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review on singlePaneDockedTabsCoordinator.ts:325 — clarify _queue JSDoc disposal guard scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review on desktopSessionLayoutController.test.ts — condense function-body comments to one line Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: handle docked tab teardown failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> Co-authored-by: Dmitriy Vasyura --- .../singlePaneDockedTabsCoordinator.ts | 61 ++++++--- .../desktopSessionLayoutController.test.ts | 121 ++++++++++++++++++ .../test/browser/layoutControllerTestUtils.ts | 8 +- 3 files changed, 171 insertions(+), 19 deletions(-) diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts index 9e9f433c5ece08..1818eaa77a0491 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDockedTabsCoordinator.ts @@ -207,7 +207,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { if (!group || group.contains(e.editor)) { return; } - void this._sequencer.queue(() => this._removeFilesTab(this._editorGroupsService.mainPart.activeGroup)).catch(onUnexpectedError); + this._queue(() => this._removeFilesTab(this._editorGroupsService.mainPart.activeGroup)); })); this._register(this._editorService.onDidCloseEditor(e => { if (e.editor instanceof EmptyFileEditorInput @@ -245,7 +245,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { } if (visible) { - void this._sequencer.queue(() => this._restoreCollapsedTabs()).catch(onUnexpectedError); + this._queue(() => this._restoreCollapsedTabs()); return; } @@ -254,7 +254,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { return; } if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - void this._sequencer.queue(() => this._collapseNonManagedTabs()).catch(onUnexpectedError); + this._queue(() => this._collapseNonManagedTabs()); } })); @@ -294,7 +294,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { : trigger; this._pending = { sessionKey, target, trigger: mergedTrigger }; const generation = ++this._generation; - void this._sequencer.queue(() => this._reconcile(generation)).catch(onUnexpectedError); + this._queue(() => this._reconcile(generation)); } private _readTarget(reader: IReader | undefined): IManagedTabsTarget { @@ -310,8 +310,24 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // --- Reconcile -------------------------------------------------------- + override dispose(): void { + // Bump the generation before super.dispose() so queued/in-flight reconciles bail at their next checkpoint. + this._generation++; + this._pending = undefined; + super.dispose(); + } + + /** Queues coordinator-owned work, dropping tasks and failures that outlive disposal. */ + private _queue(task: () => Promise): void { + void this._sequencer.queue(() => this._store.isDisposed ? Promise.resolve() : task()).catch(error => { + if (!this._store.isDisposed) { + onUnexpectedError(error); + } + }); + } + private async _reconcile(generation: number): Promise { - if (generation !== this._generation || !this._pending) { + if (this._store.isDisposed || generation !== this._generation || !this._pending) { return; } @@ -334,6 +350,9 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { private async _reconcileCore(target: IManagedTabsTarget, trigger: IReconcileTrigger, generation: number): Promise { const group = this._editorGroupsService.mainPart.activeGroup; + let groupDisposed = false; + const groupDisposeListener = Event.once(group.onWillDispose)(() => groupDisposed = true); + const isCancelled = () => this._store.isDisposed || groupDisposed || generation !== this._generation; this._resetCollapsedEditorsOnSessionChange(); const changesResource = target.changesSessionResource ? this._sessionChangesService.getChangesEditorResource(target.changesSessionResource) : undefined; @@ -345,8 +364,8 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { try { // [1] Replace an outgoing session's Changes tab in place when the incoming // session also wants Changes; close only additional stale tabs. - await this._reconcileForeignChangesEditors(group, changesResource); - if (generation !== this._generation) { + await this._reconcileForeignChangesEditors(group, changesResource, isCancelled); + if (isCancelled()) { return; } this._updateFilesEditors(group, target.workspace); @@ -354,7 +373,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { const preserveMissingFiles = !!trigger.workingSetRestored && this._preserveMissingFilesForSessionKey === sessionKey; if (preserveMissingFiles) { await this._removeFilesTab(group); - if (generation !== this._generation) { + if (isCancelled()) { return; } } @@ -376,14 +395,14 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // [3] Keep Files active by default for a new-session view. if (openFilesFirst) { await this._openFilesTab(group, target.workspace); - if (generation !== this._generation) { + if (isCancelled()) { return; } } // [4] Open Changes (active on submit so the detail panel maps to it). if (openChanges && changesResource) { - if (!await this._openChangesTab(target.changesSessionResource!, changesResource, group, generation, activateChanges)) { + if (!await this._openChangesTab(target.changesSessionResource!, changesResource, group, activateChanges, isCancelled)) { return; } } @@ -391,13 +410,18 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // [5] Open the Files placeholder after Changes for created sessions. if (openFiles && !openFilesFirst) { await this._openFilesTab(group, target.workspace); - if (generation !== this._generation) { + if (isCancelled()) { return; } } + } catch (error) { + if (!this._store.isDisposed && !groupDisposed) { + throw error; + } } finally { suppression.dispose(); - if (generation === this._generation) { + groupDisposeListener.dispose(); + if (!isCancelled()) { if (trigger.workingSetRestored) { this._preserveMissingFilesForSessionKey = undefined; } @@ -417,11 +441,11 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { // --- Tab operations --------------------------------------------------- - /** Opens the Changes editor pinned first (active on submit). Returns `false` if a newer reconcile superseded this one mid-open. */ - private async _openChangesTab(sessionResource: URI, changesResource: URI, group: IEditorGroup, generation: number, active: boolean): Promise { + /** Opens the Changes editor pinned first (active on submit). Returns `false` if the reconcile is cancelled mid-open. */ + private async _openChangesTab(sessionResource: URI, changesResource: URI, group: IEditorGroup, active: boolean, isCancelled: () => boolean): Promise { this._changesViewService.setChangesetId(undefined); await this._sessionChangesService.openChangesEditor(sessionResource, active ? CHANGES_TAB_ACTIVE_OPTIONS : CHANGES_TAB_OPTIONS, group); - if (generation !== this._generation) { + if (isCancelled()) { return false; } const changesEditor = this._findChangesEditor(group, changesResource); @@ -455,7 +479,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { } } - private async _reconcileForeignChangesEditors(group: IEditorGroup, activeChangesResource: URI | undefined): Promise { + private async _reconcileForeignChangesEditors(group: IEditorGroup, activeChangesResource: URI | undefined, isCancelled: () => boolean): Promise { const foreign = group.editors.filter(editor => { const resource = this.getChangesEditorResource(editor); return resource && (!activeChangesResource || !isEqual(resource, activeChangesResource)); @@ -476,6 +500,9 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { replacement: this._instantiationService.createInstance(SessionChangesEditorInput, activeChangesResource), options: wasActive ? CHANGES_TAB_ACTIVE_OPTIONS : CHANGES_TAB_OPTIONS, }]); + if (isCancelled()) { + return; + } if (editorsToClose.length > 0) { await this._closeManagedEditors(group, editorsToClose); } @@ -536,7 +563,7 @@ export class SinglePaneDockedTabsCoordinator extends Disposable { private _queueCollapseIfDetailsOnly(): void { if (!this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) && this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - void this._sequencer.queue(() => this._collapseNonManagedTabs()).catch(onUnexpectedError); + this._queue(() => this._collapseNonManagedTabs()); } } diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index 44f8119702192d..5f01ccb083e264 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; +import { errorHandler } from '../../../../../base/common/errors.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ISettableObservable, transaction } from '../../../../../base/common/observable.js'; @@ -2980,6 +2981,126 @@ suite('LayoutController (desktop)', () => { assert.deepStrictEqual(publishedWorkspaces, ['c']); }); + test('[managed tabs / dispose] a reconcile stalled mid-open opens no further editors once the controller is disposed', async () => { + const controller = createSinglePaneController({ activateAux: true }); + await settle(); + + // Pause the reconcile at the first Changes open so it stalls before the Files tab opens. + let releaseChangesOpen!: () => void; + const changesOpenGate = new Promise(resolve => { releaseChangesOpen = resolve; }); + let gateArmed = true; + harness.onOpenChangesEditor = () => { + if (gateArmed) { + gateArmed = false; + return changesOpenGate; + } + return undefined; + }; + + // The created session's reconcile stalls awaiting the gated Changes open before the Files tab. + harness.activeSessionObs.set(makeSession(URI.parse('session:1'), { isCreated: true, changes: [makeChange('/file.ts')] }), undefined); + await settle(); + assert.strictEqual(hasFilesTab(), false, 'reconcile should be stalled before opening the Files tab'); + + // Dispose while stalled: the generation bump on dispose must make the resumed reconcile bail before any later editor open. + controller.dispose(); + releaseChangesOpen(); + await settle(); + + assert.strictEqual(hasFilesTab(), false, 'a reconcile resumed after dispose must not open further editors'); + }); + + test('[managed tabs / dispose] ignores an in-flight editor replacement failure after the controller is disposed', async () => { + const originalUnexpectedErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const unexpectedErrors: Error[] = []; + errorHandler.setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + try { + const controller = createSinglePaneController({ activateAux: true }); + await settle(); + harness.activeSessionObs.set(makeSession(URI.parse('session:a')), undefined); + await settle(); + + let replaceStarted = false; + let rejectReplace!: (error: Error) => void; + const replaceGate = new Promise((_, reject) => { rejectReplace = reject; }); + harness.onReplaceEditors = replacements => { + replaceStarted = true; + store.add(replacements[0].replacement); + return replaceGate; + }; + + harness.activeSessionObs.set(makeSession(URI.parse('session:b')), undefined); + await settle(); + assert.strictEqual(replaceStarted, true, 'the reconcile should be stalled replacing the outgoing Changes editor'); + + controller.dispose(); + rejectReplace(new Error('InstantiationService has been disposed')); + await settle(); + + assert.deepStrictEqual(unexpectedErrors, []); + } finally { + errorHandler.setUnexpectedErrorHandler(originalUnexpectedErrorHandler); + } + }); + + test('[managed tabs / dispose] ignores an in-flight editor replacement failure after the target group is disposed', async () => { + const originalUnexpectedErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const unexpectedErrors: Error[] = []; + errorHandler.setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + try { + createSinglePaneController({ activateAux: true }); + await settle(); + harness.activeSessionObs.set(makeSession(URI.parse('session:a')), undefined); + await settle(); + + let replaceStarted = false; + let rejectReplace!: (error: Error) => void; + const replaceGate = new Promise((_, reject) => { rejectReplace = reject; }); + harness.onReplaceEditors = replacements => { + replaceStarted = true; + store.add(replacements[0].replacement); + return replaceGate; + }; + + harness.activeSessionObs.set(makeSession(URI.parse('session:b')), undefined); + await settle(); + assert.strictEqual(replaceStarted, true, 'the reconcile should be stalled replacing the outgoing Changes editor'); + + harness.onWillDisposeActiveGroup.fire(); + rejectReplace(new Error('InstantiationService has been disposed')); + await settle(); + + assert.deepStrictEqual(unexpectedErrors, []); + } finally { + errorHandler.setUnexpectedErrorHandler(originalUnexpectedErrorHandler); + } + }); + + test('[managed tabs / errors] reports an editor replacement failure while the reconcile is active', async () => { + const originalUnexpectedErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const unexpectedErrors: Error[] = []; + errorHandler.setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + try { + createSinglePaneController({ activateAux: true }); + await settle(); + harness.activeSessionObs.set(makeSession(URI.parse('session:a')), undefined); + await settle(); + + const failure = new Error('replace failed'); + harness.onReplaceEditors = replacements => { + store.add(replacements[0].replacement); + throw failure; + }; + + harness.activeSessionObs.set(makeSession(URI.parse('session:b')), undefined); + await settle(); + + assert.deepStrictEqual(unexpectedErrors, [failure]); + } finally { + errorHandler.setUnexpectedErrorHandler(originalUnexpectedErrorHandler); + } + }); + test('[managed tabs / details-only] always restores both docked inputs while only details are visible', async () => { createSinglePaneController({ activateAux: true, diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 1e337915b040af..4f68574569d036 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -215,6 +215,8 @@ export interface ITestLayoutHarness { activateAux: boolean; /** Editors in the main part's active group (drives the single-pane managed-tab logic). */ activeGroupEditors: EditorInput[]; + /** Fires when the active editor group begins disposal. */ + onWillDisposeActiveGroup: Emitter; /** Records editors closed via `IEditorService.closeEditors`. */ closedEditors: EditorInput[]; /** Records untyped editors reopened via `IEditorService.openEditors`. */ @@ -248,7 +250,7 @@ export interface ITestLayoutHarness { /** Optional async hook awaited before `closeEditors` mutates the group. */ onCloseEditors?: () => Promise | void; /** Optional async hook awaited before `replaceEditors` mutates the group. */ - onReplaceEditors?: () => Promise | void; + onReplaceEditors?: (replacements: IEditorReplacement[]) => Promise | void; /** Records every `openChangesEditor` call for assertions (session + whether active). */ openChangesEditorCalls: { sessionResource: URI; active: boolean }[]; readonly sessionChangesService: ISessionChangesService; @@ -334,6 +336,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption editorPartAutoVisibilitySuppressionDepth: 0, activateAux: options.activateAux ?? false, activeGroupEditors: [], + onWillDisposeActiveGroup: store.add(new Emitter()), closedEditors: [], openedEditors: [], closeSuppressionFlags: [], @@ -356,6 +359,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption const testActiveGroup: IEditorGroup = new class extends mock() { override readonly id = 1; override get editors() { return harness.activeGroupEditors as IEditorGroup['editors']; } + override readonly onWillDispose = harness.onWillDisposeActiveGroup.event; override readonly onWillCloseEditor = harness.onWillCloseEditor.event as IEditorGroup['onWillCloseEditor']; override get count() { return harness.activeGroupEditors.length; } override get isEmpty() { return harness.activeGroupEditors.length === 0; } @@ -365,7 +369,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption override pinEditor() { } override getIndexOfEditor(editor: EditorInput) { return harness.activeGroupEditors.indexOf(editor); } override async replaceEditors(replacements: IEditorReplacement[]) { - await harness.onReplaceEditors?.(); + await harness.onReplaceEditors?.(replacements); for (const replacement of replacements) { const index = harness.activeGroupEditors.indexOf(replacement.editor); if (index === -1) { From 0352a616b7bbca5a4d7e1f57094a32576893f509 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 1 Sep 2026 13:53:13 -0400 Subject: [PATCH 10/41] dictation: Fix provider fallback and connection failures (#333811) * Fix dictation fallback and connection failures Require cloud dictation authentication before it displaces extension providers, await editor and terminal startup, and fail promptly on rejected voice connections. Add targeted telemetry and regression coverage.\n\nFixes #333792\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dictation: Address provider lifecycle races Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../devContainerAgentHostConnector.test.ts | 27 ++--- .../speechToText/chatSpeechToTextService.ts | 111 ++++++++++++++++-- .../browser/chatSpeechToTextService.test.ts | 66 +++++++++++ .../browser/dictation/editorDictation.ts | 47 ++++---- .../voice/browser/terminalVoiceActions.ts | 7 +- .../voice/test/browser/terminalVoice.test.ts | 19 +++ 6 files changed, 229 insertions(+), 48 deletions(-) diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts index f2817fc080976d..cb808d2880140c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/devContainerAgentHostConnector.test.ts @@ -19,6 +19,11 @@ import { ensureDevContainerAgentHostsEnabled, isDevContainerWorkspaceAvailable } suite('Dev Container Agent Host Connector', () => { ensureNoDisposablesAreLeakedInTestSuite(); + const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + // Capture these before configuration registry tests clear global registrations. + const devContainerAgentHostEnabledProperty = configurationRegistry.getConfigurationProperties()[DevContainerAgentHostEnabledSettingId]; + const devContainerWorktreeEnabledProperty = configurationRegistry.getExcludedConfigurationProperties()[DevContainerWorktreeEnabledSettingId]; + test('requires Docker and a default Dev Container configuration', async () => { const workspaceUri = URI.file('/workspace'); const check = (existingPaths: readonly string[], dockerAvailable: boolean, devContainerAgentHostsEnabled = true, remoteAgentHostsEnabled = true, uri = workspaceUri) => { @@ -59,14 +64,11 @@ suite('Dev Container Agent Host Connector', () => { }); test('registers an experimental, disabled-by-default user setting', () => { - const property = Registry.as(ConfigurationExtensions.Configuration) - .getConfigurationProperties()[DevContainerAgentHostEnabledSettingId]; - assert.deepStrictEqual({ - default: property.default, - scope: property.scope, - tags: property.tags, - experiment: property.experiment, + default: devContainerAgentHostEnabledProperty.default, + scope: devContainerAgentHostEnabledProperty.scope, + tags: devContainerAgentHostEnabledProperty.tags, + experiment: devContainerAgentHostEnabledProperty.experiment, }, { default: false, scope: ConfigurationScope.APPLICATION, @@ -76,14 +78,11 @@ suite('Dev Container Agent Host Connector', () => { }); test('registers a hidden experimental setting for combining Dev Containers and worktrees', () => { - const property = Registry.as(ConfigurationExtensions.Configuration) - .getExcludedConfigurationProperties()[DevContainerWorktreeEnabledSettingId]; - assert.deepStrictEqual({ - default: property.default, - scope: property.scope, - tags: property.tags, - experiment: property.experiment, + default: devContainerWorktreeEnabledProperty.default, + scope: devContainerWorktreeEnabledProperty.scope, + tags: devContainerWorktreeEnabledProperty.tags, + experiment: devContainerWorktreeEnabledProperty.experiment, }, { default: false, scope: ConfigurationScope.APPLICATION, diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index a42898544bf6ca..2c46d3b1187a6d 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -18,6 +18,7 @@ import { INotificationService, Severity } from '../../../../../platform/notifica import { IProgress, IProgressService, IProgressStep, Progress, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { DeferredPromise, raceCancellation } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../base/common/errors.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { localize } from '../../../../../nls.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; @@ -172,6 +173,8 @@ type SpeechToTextSessionEvent = { timeToFirstTranscriptMs: number; finalizeMs: number; errorCode: string; + errorName: string; + closeCode: number; cleanupModel: DictationCleanupModel; }; type SpeechToTextSessionClassification = { @@ -187,6 +190,8 @@ type SpeechToTextSessionClassification = { timeToFirstTranscriptMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the first streamed audio chunk to the first transcript update; the backend transcription latency (excludes mic acquisition and model download). -1 when no transcript arrived.' }; finalizeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the user stopping recording until the final transcript resolved; the post-stop wait. -1 when not applicable.' }; errorCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Short error identifier when the session failed, else empty.' }; + errorName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Error type reported by the platform when the session failed, else empty.' }; + closeCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Voice websocket close code when a cloud dictation session failed, else 0.' }; cleanupModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language model used to attempt dictation cleanup, or none when no model request was made.' }; }; @@ -428,9 +433,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _entitlementCheckScheduled = false; private _startGeneration = 0; private _startInProgress: number | undefined; + private _hasGitHubSession = false; + private _githubSessionGeneration = 0; get isBusy(): boolean { - return this._state !== ChatSpeechToTextState.Idle || this._pendingStart !== undefined || this._pendingStop !== undefined; + return this._state !== ChatSpeechToTextState.Idle || this._pendingStart !== undefined || this._pendingStop !== undefined || this._startInProgress !== undefined; } get currentSurface(): ChatDictationSurface { @@ -467,6 +474,8 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _maiRevision = -1; /** Whether this dictation established the shared voice connection (and may thus tear it down). */ private _maiOwnsConnection = false; + /** Whether the active MAI startup reached a connected voice socket. */ + private _maiConnected = false; /** Resolves when the backend emits the final transcript after `ptt_end`. */ private _maiFinalTranscript: DeferredPromise | undefined; @@ -479,9 +488,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return false; } if (backend === 'mai') { - // The cloud backend needs a configured voice websocket endpoint; - // GitHub sign-in and connectivity are validated when a session starts. - return !!this._voiceWsUrl(); + return !!this._voiceWsUrl() && this._hasGitHubSession; } // On-device transcription needs no configuration — the model downloads // on first use. It is only unavailable where the platform lacks native @@ -509,6 +516,8 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _sessionSegments = 0; private _sessionPartialUpdates = 0; private _sessionErrorCode = ''; + private _sessionErrorName = ''; + private _sessionCloseCode = 0; private _sessionSurface: ChatDictationSurface = 'chat'; /** Timestamp of the first streamed audio chunk, to measure transcription latency. */ private _firstAudioMs = 0; @@ -551,6 +560,24 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._configuredContextKey = ChatContextKeys.speechToTextConfigured.bindTo(contextKeyService); this._preparingContextKey = ChatContextKeys.speechToTextPreparing.bindTo(contextKeyService); this._updateConfiguredContextKey(); + void this._refreshGitHubSession(); + this._register(this._authenticationService.onDidChangeSessions(e => { + if (e.providerId === 'github') { + void this._refreshGitHubSession(); + } + })); + this._register(this._authenticationService.onDidRegisterAuthenticationProvider(e => { + if (e.id === 'github') { + void this._refreshGitHubSession(); + } + })); + this._register(this._authenticationService.onDidUnregisterAuthenticationProvider(e => { + if (e.id === 'github') { + this._githubSessionGeneration++; + this._hasGitHubSession = false; + this._updateConfiguredContextKey(); + } + })); this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ENABLED_SETTING) || e.affectsConfiguration(DICTATION_MODEL_SETTING)) { this._updateConfiguredContextKey(); @@ -633,6 +660,23 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._configuredContextKey.set(this.isConfigured); } + private async _refreshGitHubSession(): Promise { + const generation = ++this._githubSessionGeneration; + try { + const sessions = await this._authenticationService.getSessions('github', [], { silent: true }); + if (generation !== this._githubSessionGeneration || this._store.isDisposed) { + return; + } + const hasGitHubSession = sessions.length > 0; + if (this._hasGitHubSession !== hasGitHubSession) { + this._hasGitHubSession = hasGitHubSession; + this._updateConfiguredContextKey(); + } + } catch (err) { + this._logService.warn('[chat-stt] could not refresh GitHub session state for cloud dictation', err); + } + } + private _setPreparingModel(preparing: boolean): void { if (this._isPreparingModel === preparing) { return; @@ -683,6 +727,8 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo timeToFirstTranscriptMs, finalizeMs: this._finalizeMs, errorCode: this._sessionErrorCode, + errorName: this._sessionErrorName, + closeCode: this._sessionCloseCode, cleanupModel: this._sessionCleanupModel, }); this._sessionStartMs = 0; @@ -784,6 +830,8 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._sessionSegments = 0; this._sessionPartialUpdates = 0; this._sessionErrorCode = ''; + this._sessionErrorName = ''; + this._sessionCloseCode = 0; this._sessionSurface = surface; this._firstAudioMs = 0; this._firstTranscriptMs = 0; @@ -804,6 +852,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return; } this._sessionErrorCode = this._sessionErrorCode || 'microphone'; + this._sessionErrorName = err instanceof Error ? err.name : ''; this._logSessionTelemetry('error'); this._logService.error('[chat-stt] microphone acquisition failed', err); this._notificationService.error(localize('chatStt.micError', "Could not access the microphone for speech-to-text: {0}", toErrorMessage(err))); @@ -915,6 +964,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo */ private async _startMaiSession(window: Window & typeof globalThis, generation: number): Promise { if (this._voiceClientService.isConnected) { + this._sessionErrorCode = this._sessionErrorCode || 'connect.busy'; throw new Error(localize('chatStt.maiBusy', "Cloud dictation is unavailable while Voice Mode is connected.")); } const authToken = await this._getGitHubToken(); @@ -922,6 +972,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return; } if (!authToken) { + this._sessionErrorCode = this._sessionErrorCode || 'connect.noauth'; throw new Error(localize('chatStt.maiSignIn', "Sign in to GitHub to use cloud dictation.")); } @@ -931,8 +982,12 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // A terminal close (e.g. code 4008 when another window takes over the // single voice session) stops reconnection; without this the mic would // stay open in Recording while audio is silently dropped. - this._maiSessionDisposables.add(this._voiceClientService.onFatalDisconnect(() => - this._failMaiSession(localize('chatStt.maiDisconnected', "Cloud dictation was disconnected.")))); + this._maiSessionDisposables.add(this._voiceClientService.onFatalDisconnect(e => { + if (this._maiConnected || this._state !== ChatSpeechToTextState.Idle) { + this._sessionCloseCode = e.code; + this._failMaiSession(localize('chatStt.maiDisconnected', "Cloud dictation was disconnected.")); + } + })); this._maiSessionDisposables.add(this._voiceClientService.onError(msg => this._logService.warn(`[chat-stt] voice service error during dictation: ${msg}`))); @@ -1029,9 +1084,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo * tear down the mic/session, and surface an actionable message. */ private _failMaiSession(message: string): void { - if (this._activeBackend !== 'mai' || this._state === ChatSpeechToTextState.Idle) { + if (this._activeBackend !== 'mai' || (this._state === ChatSpeechToTextState.Idle && !this._maiConnected)) { return; } + this._sessionGeneration++; + this._startGeneration++; this._sessionErrorCode = this._sessionErrorCode || 'disconnect'; this._logSessionTelemetry('error'); this._maiFinalTranscript?.complete(); @@ -1045,7 +1102,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private async _getGitHubToken(): Promise { try { const sessions = await this._authenticationService.getSessions('github'); - return sessions[0]?.accessToken; + if (sessions[0]) { + return sessions[0].accessToken; + } + const session = await this._authenticationService.createSession('github', []); + return session.accessToken; } catch (err) { this._logService.warn('[chat-stt] could not resolve a GitHub session for cloud dictation', err); return undefined; @@ -1055,22 +1116,47 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo /** Wait for the voice websocket to report connected, or reject on timeout. */ private async _awaitVoiceConnected(): Promise { if (this._voiceClientService.isConnected) { + this._maiConnected = true; return; } await new Promise((resolve, reject) => { const store = new DisposableStore(); this._maiSessionDisposables.add(store); - store.add(toDisposable(resolve)); - const timer = setTimeout(() => { - reject(new Error('Timed out connecting to the voice service.')); + let settled = false; + const settle = (error?: Error) => { + if (settled) { + return; + } + settled = true; store.dispose(); + if (error) { + reject(error); + } else { + resolve(); + } + }; + store.add(toDisposable(() => { + if (!settled) { + settled = true; + reject(new CancellationError()); + } + })); + const timer = setTimeout(() => { + this._sessionErrorCode = this._sessionErrorCode || 'connect.timeout'; + settle(new Error(localize('chatStt.maiConnectTimeout', "Timed out connecting to the voice service."))); }, MAI_CONNECT_TIMEOUT_MS); store.add(toDisposable(() => clearTimeout(timer))); store.add(this._voiceClientService.onDidChangeConnectionState(connected => { if (connected) { - store.dispose(); + this._maiConnected = true; + settle(); } })); + store.add(this._voiceClientService.onFatalDisconnect(e => { + this._sessionCloseCode = e.code; + this._sessionErrorCode = this._sessionErrorCode || `connect.rejected.${e.code}`; + settle(new Error(localize('chatStt.maiConnectRejected', "The voice service rejected the connection (code {0}).", e.code))); + })); }); } @@ -1723,6 +1809,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._maiFinalTranscript = undefined; this._maiTurnId = ''; this._maiRevision = -1; + this._maiConnected = false; // Release the shared voice connection only if this dictation owns it, so // tearing down never disconnects a session Voice Mode established. if (this._activeBackend === 'mai' && this._maiOwnsConnection) { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index fdd88deaf36474..80eee71f9df09d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -6,11 +6,14 @@ import assert from 'assert'; import sinon from 'sinon'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ChatSpeechToTextService, createDictationCleanupSystemPrompt, isDictationEntitled, selectFinalDictationTranscript, stripDictationFillers } from '../../browser/speechToText/chatSpeechToTextService.js'; import { resolveDictationLanguage } from '../../browser/speechToText/dictationLanguage.js'; import { ChatEntitlement } from '../../../../services/chat/common/chatEntitlementService.js'; import { ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; +import { IVoiceClientService, IVoiceFatalDisconnect } from '../../common/voiceClient/voiceClientService.js'; type CleanupTestService = { _configurationService: { @@ -29,6 +32,24 @@ type CleanupTestService = { _cleanupWithLanguageModel: (text: string, token: CancellationToken) => Promise; }; +type ConfiguredTestService = { + _configurationService: { getValue: () => boolean }; + _getBackend: () => 'mai'; + _isEntitledForBackend: () => boolean; + _voiceWsUrl: () => string; + _hasGitHubSession: boolean; + _localTranscription: { isSupported: boolean }; + readonly isConfigured: boolean; +}; + +type ConnectionTestService = { + _voiceClientService: Pick; + _maiSessionDisposables: DisposableStore; + _sessionErrorCode: string; + _sessionCloseCode: number; + _awaitVoiceConnected: () => Promise; +}; + suite('ChatSpeechToTextService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -61,6 +82,51 @@ suite('ChatSpeechToTextService', () => { }); }); + test('requires a GitHub session before cloud dictation is configured', () => { + const service = Object.create(ChatSpeechToTextService.prototype) as ConfiguredTestService; + service._configurationService = { getValue: () => true }; + service._getBackend = () => 'mai'; + service._isEntitledForBackend = () => true; + service._voiceWsUrl = () => 'wss://voice.example.com'; + service._localTranscription = { isSupported: true }; + + service._hasGitHubSession = false; + const signedOut = service.isConfigured; + service._hasGitHubSession = true; + const signedIn = service.isConfigured; + + assert.deepStrictEqual({ signedOut, signedIn }, { signedOut: false, signedIn: true }); + }); + + test('rejects cloud connection immediately on a fatal disconnect', async () => { + const fatalDisconnect = new Emitter(); + const sessionDisposables = new DisposableStore(); + const service = Object.create(ChatSpeechToTextService.prototype) as ConnectionTestService; + service._voiceClientService = { + isConnected: false, + onDidChangeConnectionState: Event.None, + onFatalDisconnect: fatalDisconnect.event, + }; + service._maiSessionDisposables = sessionDisposables; + service._sessionErrorCode = ''; + service._sessionCloseCode = 0; + + const connected = service._awaitVoiceConnected(); + fatalDisconnect.fire({ code: 4008, reason: 'rejected' }); + + await assert.rejects(connected, /code 4008/); + assert.deepStrictEqual({ + errorCode: service._sessionErrorCode, + closeCode: service._sessionCloseCode, + }, { + errorCode: 'connect.rejected.4008', + closeCode: 4008, + }); + + fatalDisconnect.dispose(); + sessionDisposables.dispose(); + }); + test('resolves the dictation language from Voice Mode configuration, display language, and browser locale', () => { assert.deepStrictEqual({ explicit: resolveDictationLanguage('fr-FR', 'de-DE'), diff --git a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts index 614554fc065c6c..30b241114224a3 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts @@ -15,7 +15,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { HasSpeechProvider, ISpeechService, SpeechToTextInProgress, SpeechToTextStatus } from '../../../speech/common/speechService.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../chat/browser/speechToText/chatSpeechToTextService.js'; -import { activeDictationEditor, isDictating, startDictation, stopDictation } from '../../../chat/browser/speechToText/dictationSession.js'; +import { activeDictationEditor, cancelDictation, isDictating, startDictation, stopDictation } from '../../../chat/browser/speechToText/dictationSession.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; import { EditorAction2, EditorContributionInstantiation, registerEditorContribution } from '../../../../../editor/browser/editorExtensions.js'; @@ -87,7 +87,7 @@ export class EditorDictationStartAction extends EditorAction2 { }); } - override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void { + override async runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): Promise { const dictation = EditorDictation.get(editor); // Toggle: pressing the start keybinding again while dictation is in @@ -100,23 +100,24 @@ export class EditorDictationStartAction extends EditorAction2 { const keybindingService = accessor.get(IKeybindingService); const holdMode = keybindingService.enableKeybindingHoldMode(this.desc.id); - if (holdMode) { - let shouldCallStop = false; - - const handle = setTimeout(() => { - shouldCallStop = true; - }, 500); - - holdMode.finally(() => { - clearTimeout(handle); - - if (shouldCallStop) { - EditorDictation.get(editor)?.stop(); - } - }); + if (!holdMode) { + await dictation?.start(); + return; } - EditorDictation.get(editor)?.start(); + let shouldCallStop = false; + const handle = setTimeout(() => { + shouldCallStop = true; + }, 500); + try { + await dictation?.start(); + await holdMode; + } finally { + clearTimeout(handle); + } + if (shouldCallStop) { + EditorDictation.get(editor)?.stop(); + } } } @@ -244,7 +245,7 @@ export class EditorDictation extends Disposable implements IEditorContribution { /** True while a dictation session is active in this editor. */ isInProgress(): boolean { - return !!this.editorDictationInProgress.get(); + return !!this.editorDictationInProgress.get() || activeDictationEditor() === this.editor; } async start(): Promise { @@ -272,9 +273,6 @@ export class EditorDictation extends Disposable implements IEditorContribution { this.widget.active(); disposables.add(toDisposable(() => this.widget.hide())); - this.editorDictationInProgress.set(true); - disposables.add(toDisposable(() => this.editorDictationInProgress.reset())); - disposables.add(this.editor.onDidChangeCursorPosition(() => this.widget.layout())); const window = getWindow(this.editor.getDomNode()) ?? getActiveWindow(); @@ -287,6 +285,9 @@ export class EditorDictation extends Disposable implements IEditorContribution { return; } + this.editorDictationInProgress.set(true); + disposables.add(toDisposable(() => this.editorDictationInProgress.reset())); + // When the shared session ends on its own (final transcript applied, an // error, or the model failing to load), tear down the editor-side UI. This // is registered only after the takeover in `startDictation` has settled so @@ -391,6 +392,10 @@ export class EditorDictation extends Disposable implements IEditorContribution { // Built-in dictation into this editor is owned by the shared chat // dictation session; stop it there so the final transcript is applied. if (isDictating() && activeDictationEditor() === this.editor) { + if (this.chatSpeechToTextService.state === ChatSpeechToTextState.Idle) { + cancelDictation(); + return; + } stopDictation(); return; } diff --git a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts index f0e71c68703553..da233362800d44 100644 --- a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts @@ -13,6 +13,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { EnablementState, IWorkbenchExtensionEnablementService } from '../../../../services/extensionManagement/common/extensionManagement.js'; import { HasSpeechProvider, SpeechToTextInProgress } from '../../../speech/common/speechService.js'; import { IChatSpeechToTextService } from '../../../chat/browser/speechToText/chatSpeechToTextService.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { registerActiveInstanceAction, sharedWhenClause } from '../../../terminal/browser/terminalActions.js'; import { TerminalCommandId } from '../../../terminal/common/terminal.js'; import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; @@ -26,6 +27,10 @@ export function registerTerminalVoiceActions() { title: localize2('workbench.action.terminal.startDictation', "Start Dictation in Terminal"), category: VOICE_CATEGORY, precondition: ContextKeyExpr.and( + ContextKeyExpr.or( + HasSpeechProvider, + ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.speechToTextConfigured) + ), // Keep the toggle available for terminal dictation, but not unrelated speech-to-text sessions. ContextKeyExpr.or(SpeechToTextInProgress.toNegated(), TerminalContextKeys.terminalDictationInProgress), sharedWhenClause.terminalAvailable @@ -49,7 +54,7 @@ export function registerTerminalVoiceActions() { // on-device engine (preferred), or the speech extension's provider. if (chatSpeechToTextService.isConfigured || HasSpeechProvider.getValue(contextKeyService)) { const instantiationService = accessor.get(IInstantiationService); - TerminalVoiceSession.getInstance(instantiationService).start(); + await TerminalVoiceSession.getInstance(instantiationService).start(); return; } const extensions = await extensionManagementService.getInstalled(); diff --git a/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts b/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts index 701d532eb86344..b4d515285ecbd5 100644 --- a/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts @@ -129,4 +129,23 @@ suite('TerminalVoiceSession', () => { assert.ok(sentTexts.some(t => t.includes('echo hello')), `expected dictated text to be sent, got ${JSON.stringify(sentTexts)}`); session.dispose(); }); + + test('falls back to the speech provider when built-in dictation is not configured', async () => { + let providerStarts = 0; + instantiationService.stub(ISpeechService, new class extends mock() { + override async createSpeechToTextSession() { + providerStarts++; + return { onDidChange: Event.None }; + } + }); + instantiationService.stub(IChatSpeechToTextService, new class extends mock() { + override readonly isConfigured = false; + }); + + const session = TerminalVoiceSession.getInstance(instantiationService as unknown as IInstantiationService); + await session.start(); + + assert.strictEqual(providerStarts, 1); + session.dispose(); + }); }); From ca4627ac2f8587bcabbe61d2b0e9cbc557fac815 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 1 Sep 2026 10:59:39 -0700 Subject: [PATCH 11/41] npm: remove deprecated script explorer setting (#333653) Remove deprecated npm script explorer setting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/npm/README.md | 3 +-- extensions/npm/package.json | 7 ------- extensions/npm/package.nls.json | 1 - 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/extensions/npm/README.md b/extensions/npm/README.md index d5538706019beb..6257885ff50d9e 100644 --- a/extensions/npm/README.md +++ b/extensions/npm/README.md @@ -15,7 +15,7 @@ For more information about auto detection of Tasks, see the [documentation](http ### Script Explorer -The Npm Script Explorer shows the npm scripts found in your workspace. The explorer view is enabled by the setting `npm.enableScriptExplorer`. A script can be opened, run, or debug from the explorer. +The Npm Script Explorer shows the npm scripts found in your workspace. A script can be opened, run, or debug from the explorer. ### Run Scripts from the Editor @@ -37,7 +37,6 @@ The extension fetches data from and Date: Tue, 1 Sep 2026 11:00:34 -0700 Subject: [PATCH 12/41] html: remove deprecated mirror cursor setting (#333651) Remove deprecated HTML mirror cursor setting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/html-language-features/package.json | 7 ------- extensions/html-language-features/package.nls.json | 2 -- 2 files changed, 9 deletions(-) diff --git a/extensions/html-language-features/package.json b/extensions/html-language-features/package.json index 1ab47fa07ca5a1..c84dc71018c88e 100644 --- a/extensions/html-language-features/package.json +++ b/extensions/html-language-features/package.json @@ -224,13 +224,6 @@ "default": true, "description": "%html.hover.references%" }, - "html.mirrorCursorOnMatchingTag": { - "type": "boolean", - "scope": "resource", - "default": false, - "description": "%html.mirrorCursorOnMatchingTag%", - "deprecationMessage": "%html.mirrorCursorOnMatchingTagDeprecationMessage%" - }, "html.trace.server": { "type": "string", "scope": "window", diff --git a/extensions/html-language-features/package.nls.json b/extensions/html-language-features/package.nls.json index d8390703757afc..21bbbad912d4b5 100644 --- a/extensions/html-language-features/package.nls.json +++ b/extensions/html-language-features/package.nls.json @@ -33,8 +33,6 @@ "html.completion.attributeDefaultValue.doublequotes": "Attribute value is set to \"\".", "html.completion.attributeDefaultValue.singlequotes": "Attribute value is set to ''.", "html.completion.attributeDefaultValue.empty": "Attribute value is not set.", - "html.mirrorCursorOnMatchingTag": "Enable/disable mirroring cursor on matching HTML tag.", - "html.mirrorCursorOnMatchingTagDeprecationMessage": "Deprecated in favor of `editor.linkedEditing`", "html.hover.documentation": "Show tag and attribute documentation in hover.", "html.hover.references": "Show references to MDN in hover." } From daae5a51f60719ca650dd4737bd6898dc3604a93 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:06:07 +0000 Subject: [PATCH 13/41] chore: bump @github/copilot-sdk to 1.0.13-preview.4 and @github/copilot to 1.0.83-0 (#333749) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- package-lock.json | 82 ++++++++++++++++++++-------------------- package.json | 4 +- remote/package-lock.json | 82 ++++++++++++++++++++-------------------- remote/package.json | 4 +- 4 files changed, 86 insertions(+), 86 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6705f4a27b5e49..0a585ac9ad0c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1155,9 +1155,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82.tgz", - "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", + "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -1166,20 +1166,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82", - "@github/copilot-darwin-x64": "1.0.82", - "@github/copilot-linux-arm64": "1.0.82", - "@github/copilot-linux-x64": "1.0.82", - "@github/copilot-linuxmusl-arm64": "1.0.82", - "@github/copilot-linuxmusl-x64": "1.0.82", - "@github/copilot-win32-arm64": "1.0.82", - "@github/copilot-win32-x64": "1.0.82" + "@github/copilot-darwin-arm64": "1.0.83-0", + "@github/copilot-darwin-x64": "1.0.83-0", + "@github/copilot-linux-arm64": "1.0.83-0", + "@github/copilot-linux-x64": "1.0.83-0", + "@github/copilot-linuxmusl-arm64": "1.0.83-0", + "@github/copilot-linuxmusl-x64": "1.0.83-0", + "@github/copilot-win32-arm64": "1.0.83-0", + "@github/copilot-win32-x64": "1.0.83-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82.tgz", - "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", + "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", "cpu": [ "arm64" ], @@ -1193,9 +1193,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82.tgz", - "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", + "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", "cpu": [ "x64" ], @@ -1209,9 +1209,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82.tgz", - "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", + "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", "cpu": [ "arm64" ], @@ -1228,9 +1228,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82.tgz", - "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", + "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", "cpu": [ "x64" ], @@ -1247,9 +1247,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82.tgz", - "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", + "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", "cpu": [ "arm64" ], @@ -1266,9 +1266,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82.tgz", - "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", + "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", "cpu": [ "x64" ], @@ -1285,12 +1285,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.13-preview.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.2.tgz", - "integrity": "sha512-hdMEfghZszu5sgbjZLF1aroLT6WrCk3M8W1Kfd5G5gHLe6QQ8qG8EJaTU+ITw4FN5J25k6IsbhkUO7NqSXeTIQ==", + "version": "1.0.13-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.4.tgz", + "integrity": "sha512-v8UDdtcEC/T8SfF3S0PTQi+1LtNS+7aPba+iYKDtvCXpUaoZHh66r+DZhIJxI7R+J9NESSxvF+kr8lWQoad5+Q==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.83-0", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -1300,9 +1300,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82.tgz", - "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", + "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", "cpu": [ "arm64" ], @@ -1316,9 +1316,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82.tgz", - "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", + "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 1f7f302ef53301..46547033cb9b6c 100644 --- a/package.json +++ b/package.json @@ -101,8 +101,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/remote/package-lock.json b/remote/package-lock.json index c24cdb237b936a..a61dff0c49c323 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,8 +8,8 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.8.0", @@ -61,9 +61,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.82.tgz", - "integrity": "sha512-+mDIwBO3dCpL3k2rVLc4+1tFzqcVBTJNA/0co+okEpmCgcHjaz91Cqq7++pJKN5yJQuGC6bKangm7PSoheI1Xw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.83-0.tgz", + "integrity": "sha512-Nv4IsqsveMgghwaBhgvSBZyIyvsqNBZTqnbVnv69+9+Suyq20vJcv6aB74UcJ7VPCMxIGJJUaJkugEtkMNv6wA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -72,20 +72,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.82", - "@github/copilot-darwin-x64": "1.0.82", - "@github/copilot-linux-arm64": "1.0.82", - "@github/copilot-linux-x64": "1.0.82", - "@github/copilot-linuxmusl-arm64": "1.0.82", - "@github/copilot-linuxmusl-x64": "1.0.82", - "@github/copilot-win32-arm64": "1.0.82", - "@github/copilot-win32-x64": "1.0.82" + "@github/copilot-darwin-arm64": "1.0.83-0", + "@github/copilot-darwin-x64": "1.0.83-0", + "@github/copilot-linux-arm64": "1.0.83-0", + "@github/copilot-linux-x64": "1.0.83-0", + "@github/copilot-linuxmusl-arm64": "1.0.83-0", + "@github/copilot-linuxmusl-x64": "1.0.83-0", + "@github/copilot-win32-arm64": "1.0.83-0", + "@github/copilot-win32-x64": "1.0.83-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.82.tgz", - "integrity": "sha512-UpVSFA0COmlIakAr7/6WJqJlabtKgY8y5en6La3IxGxXsLlbkGoeevSqyfXvqJyHxaGn0YGpOC1oEPL379JfCw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.83-0.tgz", + "integrity": "sha512-0KQjKS9vd4QGxLAbFJcvyv/zsC5kivrtDe0UZhHt/43nUGqoS61DFcsM596/kg75vNE6c9J4gmZ5fUPYef+0hw==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.82.tgz", - "integrity": "sha512-wMKbxK8fpKsbATjn9dE31Pae67H1xu6dmaCuyXpXjXLsNPVjeLFszJZ30MAOwxRWz4dmA8VvEJZmLgJekojo2Q==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.83-0.tgz", + "integrity": "sha512-fiyW+hy4c8AI7ONxN623f9cmJGRpbqTztc0jSVXc9z9WwzcWi39X0nxUprRM2l2Dq6YQ3guPCqGl/g1T5bQfQg==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.82.tgz", - "integrity": "sha512-YDQmh0F+GzWORPmNNxQdcjHcXHxcy08dhMhbAhu4cqtMeA2sZWQqhfk9mJhHppMm9U0uR7h0KAB6nsgw/8Bsuw==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.83-0.tgz", + "integrity": "sha512-RWbRU+KgEmtAdKp1GQVTqfdwg4Ti/OVmgZGkXq4lMYj3wnBBQcayFpSLHg5ShzDSS0RglD4b8Z27NjPrm7bXxA==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.82.tgz", - "integrity": "sha512-vqmG9665ktgLHAkGKMjp4uVMdHqLxi8uS9zL+g2pMwZUPoM7JxkaSfOoeyYr99caF7J6VIJTWv4LdRNQyG5jrQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.83-0.tgz", + "integrity": "sha512-5COXUNT+jDfkeyqrymZMvhTogkBYUXt+wuRwKrK6ol5vaw5SoDP1DYbI2hIEfoUj4g7XTHLUCD1s3lw8eicqUA==", "cpu": [ "x64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.82.tgz", - "integrity": "sha512-14dWfa4rBME55bKmF+Z8V+WzZNgPQZKFFBQX+EOiAgLVV/arQCnQ1m7wwa5oXjQeCIgkS/L9J8uM8jNm6cZbOA==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.83-0.tgz", + "integrity": "sha512-7sYf364iz6s97ClviBRQusTKz3S3TgoKniyYv8+aRi5f5w6TL8NTPnGX1bXMeU0VZmk5VKQTlxVRO2yA4uFwpg==", "cpu": [ "arm64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.82.tgz", - "integrity": "sha512-Mauqa2TBjtB8W/1KXF/jJ4MbtwnLvoEQNHYoSyoX+s+nIpttmixmYBJDwKV89ZlQRdjOCuxOgraRLQ9hjjXvNQ==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.83-0.tgz", + "integrity": "sha512-jze/f6Yd3Y83kxUa88kXUiwHlZmHDwAqudswdHT6f6q+K1ZEELFGEzbB6Ku4i0L8M6wHXO1EF/zaiSFWQaM4Tw==", "cpu": [ "x64" ], @@ -191,12 +191,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.13-preview.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.2.tgz", - "integrity": "sha512-hdMEfghZszu5sgbjZLF1aroLT6WrCk3M8W1Kfd5G5gHLe6QQ8qG8EJaTU+ITw4FN5J25k6IsbhkUO7NqSXeTIQ==", + "version": "1.0.13-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13-preview.4.tgz", + "integrity": "sha512-v8UDdtcEC/T8SfF3S0PTQi+1LtNS+7aPba+iYKDtvCXpUaoZHh66r+DZhIJxI7R+J9NESSxvF+kr8lWQoad5+Q==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.82-0", + "@github/copilot": "^1.0.83-0", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -206,9 +206,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.82.tgz", - "integrity": "sha512-PaW9s0GTgM+svtDkB583dZRrhLrO4o10y2ozMmQ0Hi5eB11C24UdC5U81nFlvKPED7+GlnBJIFQ+oHaxHrnp3A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.83-0.tgz", + "integrity": "sha512-93jln98UAJpslMQ7n+wAmCpoOWGEV5lXxV/DaEajySvYrCU33D2yj7d9kl8X2CgaUVBas6sNWSWKtqy+rKJxXQ==", "cpu": [ "arm64" ], @@ -222,9 +222,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.82", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.82.tgz", - "integrity": "sha512-m4iSROOMEPp1yRIQQwbnO0CDfwB4syESyHoeSLLFuynMR4/ApghAdyBrBQQcTKGsUu3R5rOE64RYlVmyKmuA9A==", + "version": "1.0.83-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.83-0.tgz", + "integrity": "sha512-+4Htk3CixO1qcOtYegjn33/8bSDdx8QXDpgVBak2D4Y5hzBWPO5IuQoICwvjaW5VOIW+I7Q62RK2pupSjxB38Q==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index f8ce30a06757ad..af7d55252059c3 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "1.0.82", - "@github/copilot-sdk": "1.0.13-preview.2", + "@github/copilot": "1.0.83-0", + "@github/copilot-sdk": "1.0.13-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.8.0", From 3a5a3e7ebe746737cc880cb082ee1d5539de3b44 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 1 Sep 2026 14:12:39 -0400 Subject: [PATCH 14/41] chat: gate Voice Mode by account and policy (#333810) * Gate Voice Mode by account and policy Disable Voice Mode for external Copilot Business and Enterprise accounts, and honor the Copilot preview-features policy.\n\nFixes #333790\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stop Voice Mode when policy disables it Treat the effective Voice Mode setting as a runtime connection gate, and avoid global contribution registration in the policy test so browser tests remain isolated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/policies/policyData.jsonc | 15 ++ .../browser/agentsVoice.contribution.ts | 15 +- .../contrib/agentsVoice/common/agentsVoice.ts | 5 + .../test/browser/voiceModeOnboarding.test.ts | 14 +- .../voiceClient/voiceSessionController.ts | 21 ++- .../voiceSessionController.test.ts | 171 ++++++++++++++---- 6 files changed, 198 insertions(+), 43 deletions(-) diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 6a409588d09800..1962a937ec3ec7 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -38,6 +38,21 @@ } ], "policies": [ + { + "key": "agents.voice.enabled", + "name": "AgentsVoice", + "category": "InteractiveSession", + "minimumVersion": "1.137", + "localization": { + "description": { + "key": "agents.voice.enabled", + "value": "Enable the Voice Mode panel in the chat view for voice-driven coding conversations." + } + }, + "type": "boolean", + "default": false, + "included": true + }, { "key": "chat.agent.allowedNetworkDomains", "name": "ChatAgentAllowedNetworkDomains", diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index a5a281f743ac3c..a254b31da13c45 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -21,6 +21,7 @@ import './transcriptsView/voiceTranscripts.contribution.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { PolicyCategory } from '../../../../base/common/policy.js'; import { URI } from '../../../../base/common/uri.js'; import * as nls from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -35,7 +36,7 @@ import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 import { ConfigurationKeyValuePairs, IConfigurationMigrationRegistry, Extensions as WorkbenchConfigurationExtensions } from '../../../common/configuration.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { AgentsVoiceSettingId, AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING, AGENTS_VOICE_ENABLED, AGENTS_VOICE_ENTITLED, AGENTS_VOICE_LISTENING, AGENTS_VOICE_RECONNECTING } from '../common/agentsVoice.js'; +import { AgentsVoiceSettingId, AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING, AGENTS_VOICE_ENABLED, AGENTS_VOICE_ENTITLED, AGENTS_VOICE_LISTENING, AGENTS_VOICE_RECONNECTING, getAgentsVoicePolicyValue } from '../common/agentsVoice.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -597,6 +598,18 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], scope: ConfigurationScope.APPLICATION, restricted: true, + policy: { + name: 'AgentsVoice', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.137', + value: getAgentsVoicePolicyValue, + localization: { + description: { + key: 'agents.voice.enabled', + value: nls.localize('agents.voice.enabled', "Enable the Voice Mode panel in the chat view for voice-driven coding conversations."), + }, + }, + }, }, [AgentsVoiceSettingId.ShowButton]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts index 942931740b51b8..47d9306e21da2f 100644 --- a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts +++ b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts @@ -5,6 +5,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { Event } from '../../../../base/common/event.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; @@ -34,6 +35,10 @@ export const AGENTS_VOICE_ENABLED = ContextKeyExpr.and( AGENTS_VOICE_ENTITLED, )!; +export function getAgentsVoicePolicyValue(policyData: IPolicyData): false | undefined { + return policyData.chat_preview_features_enabled === false ? false : undefined; +} + export const enum AgentsVoiceSettingId { ShowButton = 'agents.voice.showButton', } diff --git a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts index 4e52daaea58328..f60ff9c9027fd8 100644 --- a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts +++ b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts @@ -17,7 +17,7 @@ import { IAccessibilityService } from '../../../../../platform/accessibility/com import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { AgentsVoiceStorageKeys } from '../../common/agentsVoice.js'; +import { AgentsVoiceStorageKeys, getAgentsVoicePolicyValue } from '../../common/agentsVoice.js'; import { IVoiceSessionController, VoiceState } from '../../../chat/browser/voiceClient/voiceSessionController.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { VoiceModeOnboardingBanner, VoiceModeOnboardingService } from '../../browser/voiceModeOnboarding.js'; @@ -92,6 +92,18 @@ suite('Voice Mode onboarding', () => { return store.add(instantiationService.createInstance(VoiceModeOnboardingService)); } + test('disables Voice Mode when preview features are disabled by policy', () => { + assert.deepStrictEqual([ + getAgentsVoicePolicyValue({ chat_preview_features_enabled: false }), + getAgentsVoicePolicyValue({ chat_preview_features_enabled: true }), + getAgentsVoicePolicyValue({}), + ], [ + false, + undefined, + undefined, + ]); + }); + test('auditions a voice, dismisses, and never returns', () => { const telemetryEvents: ITelemetryEvent[] = []; const service = createService(disposables, [], [], telemetryEvents); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 8f26164883139a..2719423a5a0ae7 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -66,7 +66,9 @@ export type VoiceState = 'idle' | 'listening' | 'processing' | 'speaking' | 'err export function isVoiceEntitled(chatEntitlementService: IChatEntitlementService): boolean { return isProUser(chatEntitlementService.entitlement) - && (chatEntitlementService.entitlement !== ChatEntitlement.Enterprise || chatEntitlementService.isInternal); + && (chatEntitlementService.isInternal + || (chatEntitlementService.entitlement !== ChatEntitlement.Business + && chatEntitlementService.entitlement !== ChatEntitlement.Enterprise)); } /** One buffered audio chunk of a deferred response. */ @@ -849,6 +851,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } }); })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('agents.voice.enabled') && !this._isVoiceModeEnabled()) { + this.disconnect(); + } + })); // Track the focused chat session so we can defer voice responses that // arrive for a session the user isn't currently looking at, and flush @@ -1050,9 +1057,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC async connect(window: Window & typeof globalThis): Promise { if (this._isConnecting.get() || this._isConnected.get()) { return; } + if (!this._isVoiceModeEnabled()) { + this.notificationService.warn(localize('voiceMode.disabled', "Voice Mode is disabled.")); + return; + } if (!isVoiceEntitled(this.chatEntitlementService)) { - this.notificationService.warn(this.chatEntitlementService.entitlement === ChatEntitlement.Enterprise - ? localize('voiceMode.enterpriseUnavailable', "Voice Mode is not available for GitHub Copilot Enterprise accounts.") + this.notificationService.warn(this.chatEntitlementService.entitlement === ChatEntitlement.Business || this.chatEntitlementService.entitlement === ChatEntitlement.Enterprise + ? localize('voiceMode.organizationUnavailable', "Voice Mode is not available for GitHub Copilot Business or Enterprise accounts.") : localize('voiceMode.requiresPaidPlan', "Voice Mode requires a paid GitHub Copilot plan.")); return; } @@ -2159,6 +2170,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._armConnectWatchdog(); } + private _isVoiceModeEnabled(): boolean { + return this.configurationService.getValue('agents.voice.enabled') === true; + } + setActiveWindow(window: Window & typeof globalThis): void { this._window = window; this._windowFocusDisposables.clear(); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index f8523eb4bdeb16..db1664ba3e2dbe 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -17,8 +17,8 @@ import { IAccessibilityService } from '../../../../../../platform/accessibility/ import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService as BaseTestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotification, INotificationHandle, INotificationService, IPromptChoice, NoOpNotification, Severity } from '../../../../../../platform/notification/common/notification.js'; import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; @@ -47,6 +47,12 @@ import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; import { AskQuestionsToolId } from '../../../common/tools/builtinTools/askQuestionsTool.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; +class TestConfigurationService extends BaseTestConfigurationService { + constructor(configuration: Record = {}) { + super({ 'agents.voice.enabled': true, ...configuration }); + } +} + class TestVoiceClientService extends mock() { private narrationCounter = 0; readonly requests: { sessionId: string; kind: VoiceNarrationKind; text: string; narrationId: string; pendingId?: string; checkpoint?: IVoiceCheckpointNarrationMetadata; confirmationType?: VoiceConfirmationType }[] = []; @@ -827,32 +833,104 @@ suite('VoiceSessionController', () => { assert.deepStrictEqual(notificationService.notifications.map(notification => notification.message), ['Voice Mode requires a paid GitHub Copilot plan.']); }); - test('disconnects when the paid Copilot entitlement is lost', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatEntitlementService = new MutableTestChatEntitlementService(); - chatEntitlementService.entitlement = ChatEntitlement.Pro; + test('does not connect when Voice Mode is disabled', async () => { + const notificationService = new VoiceTestNotificationService(); const controller = createController( - voiceClientService, - undefined, - undefined, + new TestVoiceClientService(), undefined, undefined, undefined, undefined, + new TestConfigurationService({ 'agents.voice.enabled': false }), undefined, undefined, undefined, - chatEntitlementService, + notificationService, ); - controller['_isConnected'].set(true, undefined); - chatEntitlementService.setEntitlement(ChatEntitlement.Free); - await new Promise(resolve => queueMicrotask(resolve)); + await controller.connect(mainWindow); - assert.strictEqual(controller.isConnected.get(), false); + assert.deepStrictEqual({ + connecting: controller.isConnecting.get(), + connected: controller.isConnected.get(), + notifications: notificationService.notifications.map(notification => notification.message), + }, { + connecting: false, + connected: false, + notifications: ['Voice Mode is disabled.'], + }); + }); + + test('disconnects active and in-flight connections when Voice Mode becomes disabled', async () => { + const results = []; + for (const state of ['connecting', 'connected'] as const) { + const configurationService = new TestConfigurationService({ 'agents.voice.enabled': true }); + const controller = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + configurationService, + ); + if (state === 'connecting') { + controller['_isConnecting'].set(true, undefined); + } else { + controller['_isConnected'].set(true, undefined); + } + + await configurationService.setUserConfiguration('agents.voice.enabled', false); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === 'agents.voice.enabled'; + } + }); + results.push({ + state, + connecting: controller.isConnecting.get(), + connected: controller.isConnected.get(), + }); + } + + assert.deepStrictEqual(results, [ + { state: 'connecting', connecting: false, connected: false }, + { state: 'connected', connecting: false, connected: false }, + ]); + }); + + test('disconnects when the Copilot entitlement becomes ineligible', async () => { + const results = []; + for (const entitlement of [ChatEntitlement.Free, ChatEntitlement.Business, ChatEntitlement.Enterprise]) { + const chatEntitlementService = new MutableTestChatEntitlementService(); + chatEntitlementService.entitlement = ChatEntitlement.Pro; + const controller = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + chatEntitlementService, + ); + controller['_isConnected'].set(true, undefined); + + chatEntitlementService.setEntitlement(entitlement); + await new Promise(resolve => queueMicrotask(resolve)); + results.push({ entitlement, connected: controller.isConnected.get() }); + } + + assert.deepStrictEqual(results, [ + { entitlement: ChatEntitlement.Free, connected: false }, + { entitlement: ChatEntitlement.Business, connected: false }, + { entitlement: ChatEntitlement.Enterprise, connected: false }, + ]); }); - test('stays connected across a paid-to-paid entitlement transition', async () => { + test('stays connected across an eligible paid-to-paid entitlement transition', async () => { const chatEntitlementService = new MutableTestChatEntitlementService(); chatEntitlementService.entitlement = ChatEntitlement.Pro; const controller = createController( @@ -870,29 +948,38 @@ suite('VoiceSessionController', () => { ); controller['_isConnected'].set(true, undefined); - chatEntitlementService.transitionEntitlement(ChatEntitlement.Unresolved, ChatEntitlement.Business); + chatEntitlementService.transitionEntitlement(ChatEntitlement.Unresolved, ChatEntitlement.ProPlus); await new Promise(resolve => queueMicrotask(resolve)); assert.strictEqual(controller.isConnected.get(), true); }); - test('restricts Voice Mode for external Enterprise users but allows internal staff', async () => { - const externalNotifications = new VoiceTestNotificationService(); - const externalEntitlement = new MutableTestChatEntitlementService(); - externalEntitlement.entitlement = ChatEntitlement.Enterprise; - const externalController = createController( - new TestVoiceClientService(), - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - externalNotifications, - externalEntitlement, - ); + test('restricts Voice Mode for external Business and Enterprise users but allows internal staff', async () => { + const externalResults = []; + for (const entitlement of [ChatEntitlement.Business, ChatEntitlement.Enterprise]) { + const notifications = new VoiceTestNotificationService(); + const entitlementService = new MutableTestChatEntitlementService(); + entitlementService.entitlement = entitlement; + const controller = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + notifications, + entitlementService, + ); + await controller.connect(mainWindow); + externalResults.push({ + entitlement, + connecting: controller.isConnecting.get(), + notifications: notifications.notifications.map(notification => notification.message), + }); + } const internalNotifications = new VoiceTestNotificationService(); const internalEntitlement = new InternalTestChatEntitlementService(); @@ -911,17 +998,25 @@ suite('VoiceSessionController', () => { internalEntitlement, ); - await externalController.connect(mainWindow); await internalController.connect(mainWindow); assert.deepStrictEqual({ - externalConnecting: externalController.isConnecting.get(), - externalNotifications: externalNotifications.notifications.map(notification => notification.message), + externalResults, internalConnecting: internalController.isConnecting.get(), internalNotifications: internalNotifications.notifications.map(notification => notification.message), }, { - externalConnecting: false, - externalNotifications: ['Voice Mode is not available for GitHub Copilot Enterprise accounts.'], + externalResults: [ + { + entitlement: ChatEntitlement.Business, + connecting: false, + notifications: ['Voice Mode is not available for GitHub Copilot Business or Enterprise accounts.'], + }, + { + entitlement: ChatEntitlement.Enterprise, + connecting: false, + notifications: ['Voice Mode is not available for GitHub Copilot Business or Enterprise accounts.'], + }, + ], internalConnecting: true, internalNotifications: [], }); From 2bb7d705d30c913050328ddc74a23e59d1b9a5c8 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:15:51 -0700 Subject: [PATCH 15/41] remove flaky pet test for now (#333826) --- .../test/browser/widget/chatPetWidget.test.ts | 65 +------------------ 1 file changed, 1 insertion(+), 64 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 166f46374b43ee..3a58e4c1e6fdb9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -24,7 +24,7 @@ import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAcce import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; -import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_OVERLAY_CLASS, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_WINDOW_OWNERSHIP_CHANNEL, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, IChatPetWidgetHost, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnchoredHorizontalPosition, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalAnchor, getChatPetHorizontalPosition, getChatPetPillPlatformTop, getChatPetPlatformTop, getChatPetStackPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldClaimChatPetWindowOnConstruction, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; +import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_OVERLAY_CLASS, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, IChatPetWidgetHost, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnchoredHorizontalPosition, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalAnchor, getChatPetHorizontalPosition, getChatPetPillPlatformTop, getChatPetPlatformTop, getChatPetStackPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldClaimChatPetWindowOnConstruction, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; suite('ChatPetWidget', () => { @@ -576,69 +576,6 @@ suite('ChatPetWidget', () => { }); }); - test('keeps the pet on external-app blur but transfers it to another VS Code window', async () => { - const parent = mainWindow.document.createElement('div'); - const dragBounds = mainWindow.document.createElement('div'); - const movementBounds = mainWindow.document.createElement('div'); - mainWindow.document.body.append(parent, dragBounds, movementBounds); - disposables.add(toDisposable(() => { - parent.remove(); - dragBounds.remove(); - movementBounds.remove(); - })); - const hostService = new class extends mock() { - override readonly hasFocus = true; - override readonly onDidChangeFocus = Event.None; - override readonly onDidChangeActiveWindow = Event.None; - }(); - const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); - disposables.add(new ChatPetWidget( - createPetHost(parent, dragBounds, movementBounds), - undefined, - service, - new TestAccessibilityService(), - new class extends mock() { }(), - new class extends mock() { }(), - new NullLogService(), - hostService, - )); - const button = parent.getElementsByClassName('chat-pet-button')[0]; - service.toggle(); - const initiallyHidden = button.classList.contains('hidden'); - const ownershipChannel = new BroadcastChannel(CHAT_PET_WINDOW_OWNERSHIP_CHANNEL); - disposables.add(toDisposable(() => ownershipChannel.close())); - - mainWindow.dispatchEvent(new FocusEvent('blur')); - const hiddenAfterExternalBlur = button.classList.contains('hidden'); - const windowTransferred = new Promise(resolve => { - const observer = new mainWindow.MutationObserver(() => { - if (button.classList.contains('hidden')) { - observer.disconnect(); - resolve(); - } - }); - disposables.add(toDisposable(() => observer.disconnect())); - observer.observe(button, { attributes: true, attributeFilter: ['class'] }); - }); - ownershipChannel.postMessage({ windowId: mainWindow.vscodeWindowId + 1 }); - await windowTransferred; - const hiddenAfterWindowTransfer = button.classList.contains('hidden'); - mainWindow.dispatchEvent(new FocusEvent('focus')); - const hiddenAfterReturn = button.classList.contains('hidden'); - - assert.deepStrictEqual({ - initiallyHidden, - hiddenAfterExternalBlur, - hiddenAfterWindowTransfer, - hiddenAfterReturn, - }, { - initiallyHidden: false, - hiddenAfterExternalBlur: false, - hiddenAfterWindowTransfer: true, - hiddenAfterReturn: false, - }); - }); - test('tracks only the active VS Code renderer window', () => { assert.deepStrictEqual({ windowActive: [ From 0667825443556e8cd7e0a23045611380ef0406f2 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:20:58 -0700 Subject: [PATCH 16/41] sessions: Allow experiment to control cloud sandbox default (#333825) Mark `chat.agentHost.cloudSandbox.enabled` as experiment-driven so its default can be rolled out to a subset of accounts without shipping a code change, starting with the internal team. `auto` rather than `startup` because the assignment filters only learn that an account is internal once the entitlement resolves, which lands after startup. With `startup` the treatment would latch before that and internal users would stay off until a restart. The setting still defaults to `false`, so this is inert until the `config.chat.agentHost.cloudSandbox.enabled` flag is configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../remoteAgentHost/browser/remoteAgentHost.contribution.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index c975ffebbcea5a..a3af5d4431b5ce 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -566,6 +566,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis default: false, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, }, 'chat.sshRemoteAgentHostCommand': { type: 'string', From 670698eafccc3d6513cee09c88d48cd65650960b Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Tue, 1 Sep 2026 20:36:43 +0200 Subject: [PATCH 17/41] refactor handling of enabled/disabled state of built-in skills in the Agents window (#332726) * revert #329786 * feat: enhance prompts service to handle user-disabled built-in skills --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 6 - ...emoteAgentHostCustomizationHarness.test.ts | 27 ++- .../agentCustomizationItemProvider.ts | 35 +++- .../agentHost/agentHostLocalCustomizations.ts | 12 +- .../aiCustomizationItemSource.ts | 166 ++++++++---------- .../aiCustomizationItemsModel.ts | 2 +- .../promptSyntax/service/promptsService.ts | 10 -- .../agentCustomizationItemProvider.test.ts | 117 ++++++++++++ ...erateLocalCustomizationsForHarness.test.ts | 70 +++----- .../resolveCustomizationRefs.test.ts | 13 +- .../aiCustomizationItemsModel.test.ts | 82 +-------- ...aiCustomizationManagementEditor.fixture.ts | 6 +- 12 files changed, 283 insertions(+), 263 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index c4f1dca5e9a577..3d017432d98eeb 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -110,12 +110,6 @@ Changes to that item shape must remain aligned across: New fields should be optional unless the proposal explicitly introduces a breaking version. -## Enabling and disabling built-in skills - -Built-in discovery and user enablement are separate stores. Discovery determines which built-in items exist; enablement records the user's disabled set. Item projection combines both and keeps the built-in source distinct from extension and user storage. - -Harness filtering must happen before enablement presentation so an item hidden from a harness cannot be reintroduced by its stored enablement state. - ## Feature gating Customization surfaces are hidden when AI features are disabled. Contributions use `ChatContextKeys.enabled` for declarative visibility and the applicable entitlement state for programmatic hiding. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index a66ae6c3ec4295..d8618ae91b27d7 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -26,12 +27,22 @@ import { IAICustomizationWorkspaceService } from '../../../../../../workbench/co import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js'; import { CustomizationHarnessServiceBase, IHarnessDescriptor } from '../../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { MockPromptsService } from '../../../../../../workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.js'; +import { MockPromptsService as BaseMockPromptsService } from '../../../../../../workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { IAgentHostCustomizationService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { AgentCustomizationItemProvider } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; +class MockPromptsService extends BaseMockPromptsService { + override getDisabledPromptFiles(): ResourceSet { + return new ResourceSet(); + } + + override async listPromptFilesForStorage(): Promise<[]> { + return []; + } +} + class MockAgentConnection extends mock() { private readonly _onDidAction = new Emitter(); @@ -236,6 +247,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [pluginA, pluginB]), + new MockPromptsService(), )); const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); @@ -253,6 +265,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); provider.setDraftCustomAgents(observableValue('draftAgents', [{ type: CustomizationType.Agent, @@ -294,6 +307,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [hostScoped]), + new MockPromptsService(), )); connection.fireAction({ @@ -336,6 +350,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [hostPlugin]), + new MockPromptsService(), )); connection.fireAction({ @@ -424,6 +439,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -469,6 +485,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -515,6 +532,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [pluginRef]), + new MockPromptsService(), )); connection.fireAction({ @@ -553,6 +571,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [pluginRef]), + new MockPromptsService(), )); let changeCount = 0; @@ -635,6 +654,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -703,6 +723,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [plugin]), + new MockPromptsService(), )); const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); @@ -761,6 +782,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -811,6 +833,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ channel: agentHostSessionId, @@ -870,6 +893,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, []), + new MockPromptsService(), )); connection.fireAction({ @@ -928,6 +952,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { fileService, new NullLogService(), createTestCustomAgentsService(connection, [plugin]), + new MockPromptsService(), )); const harnessId = 'remote-agent-host-test'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index f4efe138f8e77c..c0c6dc804d0ef3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { autorun, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { basename, dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, type CustomizationDisabledReason } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { CustomizationLoadStatus, CustomizationType, type AgentCustomization, type ChildCustomization, type ClientPluginCustomization, type Customization, type CustomizationLoadState, type DirectoryCustomization, PluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -23,7 +23,7 @@ import { PromptsType, Target } from '../../../common/promptSyntax/promptTypes.js import { AgentCustomizationContentExpander } from './agentCustomizationContentExpander.js'; import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { type ISyncedCustomizationOrigin } from './syncedCustomizationBundler.js'; -import { IAgentSource, ICustomAgent, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage as PromptStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { localize } from '../../../../../../nls.js'; import { getAgentHostPluginEnablementActions } from '../../agentPluginActions.js'; @@ -53,6 +53,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, @IAgentHostCustomizationService private readonly _customAgentsService: IAgentHostCustomizationService, + @IPromptsService private readonly _promptsService: IPromptsService, ) { super(); this._contentExpander = new AgentCustomizationContentExpander(this._fileService, this._logService); @@ -60,6 +61,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto this._register(this._customAgentsService.onDidChangeCustomizations(() => { this._onDidChange.fire(); })); + this._register(this._promptsService.onDidChangeSkills(() => { + this._onDidChange.fire(); + })); } setDraftCustomAgents(customAgents: IObservable): void { @@ -219,7 +223,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto enabled: true, // fill default/empty values for all other properties they will not be used by the UI // when making a request, all that's needed is the agent id. - source: { storage: PromptsStorage.local } satisfies IAgentSource, + source: { storage: PromptStorage.local } satisfies IAgentSource, tools: undefined, agents: undefined, argumentHint: undefined, @@ -324,6 +328,31 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto }); } } + + const disabledBuiltinSkills = this._promptsService.getDisabledPromptFiles(PromptsType.skill); + const builtinSkills = await this._promptsService.listPromptFilesForStorage(PromptsType.skill, PromptStorage.builtIn, token); + if (token.isCancellationRequested) { + return []; + } + for (const skill of builtinSkills) { + if (!disabledBuiltinSkills.has(skill.uri)) { + continue; + } + const key = skill.uri.toString(); + const existing = items.get(key); + items.set(key, { + ...existing, + uri: skill.uri, + type: PromptsType.skill, + name: skill.name ?? existing?.name ?? basename(dirname(skill.uri)), + description: skill.description ?? existing?.description, + source: AICustomizationSources.builtin, + extensionId: undefined, + pluginUri: undefined, + enabled: false, + userInvocable: true, + }); + } return [...items.values()]; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index b09da01d5b5df7..18e5781b97d560 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -71,13 +71,6 @@ export interface ILocalCustomizationFile { * (to render disable affordances) and the agent host wire (to compute the * `customizations` set published via `activeClientSet`). * - * A file counts as opted out when the per-harness sync provider has it disabled, - * or when the user disabled it in the Customizations UI - * (`IPromptsService.getDisabledPromptFiles`) and that customization is one the - * UI can re-enable ({@link isUserToggleableCustomization}). See "Enabling and - * Disabling Built-in Skills" in `src/vs/sessions/AI_CUSTOMIZATIONS.md` for why - * the second store is scoped rather than honoured for every prompt type. - * * Built-in skills bundled with the Agents app (only present when the * sessions-aware prompts service is in play) are also enumerated so that * `/create-pr`, `/merge`, etc. are available to every agent host without @@ -103,7 +96,7 @@ export async function enumerateLocalCustomizationsForHarness( ); for (let i = 0; i < lists.length; i++) { const source = storageSources[i]; - const honourUserDisabled = isUserToggleableCustomization(type, source); + const userToggleable = isUserToggleableCustomization(type, source); for (const file of lists[i]) { if (matchesSessionType(file.sessionTypes, sessionType) && !seenUris.has(file.uri)) { seenUris.add(file.uri); @@ -113,8 +106,7 @@ export async function enumerateLocalCustomizationsForHarness( source, pluginUri: file.pluginUri, extensionId: file.extension?.identifier.value, - disabled: syncProvider.isDisabled(file.uri) - || (honourUserDisabled && userDisabled.has(file.uri)), + disabled: syncProvider.isDisabled(file.uri) || (userToggleable && userDisabled.has(file.uri)), }); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 29810ad2459eee..188785b1fcb761 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -254,90 +254,6 @@ export class AICustomizationItemNormalizer { // #endregion -// #region Built-in skills - -/** - * Merges built-in skills (bundled with the app under `vs/sessions/skills/`) - * into an item provider's items, deduped by URI so a copy the provider - * re-discovered is replaced by the authoritative entry, and tagged - * `groupKey: BUILTIN_STORAGE`. User-authored overrides (different URI, same - * name) are preserved. - * - * `enabled` is derived from `getDisabledPromptFiles` alone, so a built-in that - * the wire dropped from the agent-host bundle stays listed as disabled and can - * be re-enabled. This is the restore path that `isUserToggleableCustomization` - * guards, and it is why that predicate must stay in sync with what this merges - * back. See "Enabling and Disabling Built-in Skills" in - * `src/vs/sessions/AI_CUSTOMIZATIONS.md`. - * - * A workbench that uses the base `PromptsService` contributes no built-in - * skills, so `builtinPaths` is empty and the items are returned unchanged. - */ -export async function mergeBuiltinSkills( - items: readonly IAICustomizationListItem[], - promptType: PromptsType, - promptsService: IPromptsService, - _workspaceService: IAICustomizationWorkspaceService, - itemNormalizer: AICustomizationItemNormalizer, -): Promise { - const builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = await promptsService.listPromptFilesForStorage(PromptsType.skill, PromptsStorage.builtIn, CancellationToken.None); - if (builtinPaths.length === 0) { - return [...items]; - } - - const builtinUris = new ResourceMap(); - for (const p of builtinPaths) { - builtinUris.set(p.uri, p); - } - - // Drop provider items that are the same URI as a built-in (the provider - // re-discovered the bundled copy by scanning disk). - const deduped = items.filter(item => !builtinUris.has(item.uri)); - - // Collect names of user/workspace skills so we can hide the built-in - // copy once the user has added an override at either level. - const overriddenNames = new Set(); - for (const item of deduped) { - if (item.source === AICustomizationSources.local || item.source === AICustomizationSources.user) { - if (item.name) { - overriddenNames.add(item.name); - } - } - } - - // Append authoritative built-in entries (excluding any that have been - // overridden by a workspace or user copy with the same name). - const uriUseCounts = new ResourceMap(); - for (const item of deduped) { - uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); - } - const appended: IAICustomizationListItem[] = []; - const disabledPromptFiles = promptsService.getDisabledPromptFiles(PromptsType.skill); - for (const p of builtinPaths) { - const name = p.name ?? basename(p.uri); - if (overriddenNames.has(name)) { - continue; - } - const builtinItem: ICustomizationItem = { - uri: p.uri, - type: PromptsType.skill, - name, - description: p.description, - source: AICustomizationSources.builtin, - groupKey: BUILTIN_STORAGE, - enabled: !disabledPromptFiles.has(p.uri), - extensionId: undefined, - pluginUri: undefined, - userInvocable: true, - }; - appended.push(itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); - } - - return [...deduped, ...appended]; -} - -// #endregion - /** * Item source backed by a session-scoped customization item provider. */ @@ -407,7 +323,7 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); if (promptType === PromptsType.skill) { - return mergeBuiltinSkills(normalized, promptType, this.promptsService, this.workspaceService, this.itemNormalizer); + return this.mergeBuiltinSkills(normalized, promptType); } return normalized; } @@ -420,6 +336,74 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati return (await this.itemProvider.provideSourceFolders(this.sessionResource, promptType, CancellationToken.None)) ?? []; } + /** + * Merges built-in skills (bundled with the app under `vs/sessions/skills/`) + * into the provider's items. The provider may re-discover the bundled + * copies when scanning disk — those duplicates are dropped (deduped by + * URI) and replaced with the authoritative built-in entry tagged + * `groupKey: BUILTIN_STORAGE` so the UI renders them in the "Built-in" + * group. User-authored overrides (different URI, same name) are preserved. + * + * A workbench that uses the base `PromptsService` contributes no built-in + * skills, so `builtinPaths` is empty and the items are returned unchanged. + */ + private async mergeBuiltinSkills(items: readonly IAICustomizationListItem[], promptType: PromptsType): Promise { + const builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = await this.promptsService.listPromptFilesForStorage(PromptsType.skill, PromptsStorage.builtIn, CancellationToken.None); + if (builtinPaths.length === 0) { + return [...items]; + } + + const builtinUris = new ResourceMap(); + for (const p of builtinPaths) { + builtinUris.set(p.uri, p); + } + + // Drop provider items that are the same URI as a built-in (the provider + // re-discovered the bundled copy by scanning disk). + const deduped = items.filter(item => !builtinUris.has(item.uri)); + + // Collect names of user/workspace skills so we can hide the built-in + // copy once the user has added an override at either level. + const overriddenNames = new Set(); + for (const item of deduped) { + if (item.source === AICustomizationSources.local || item.source === AICustomizationSources.user) { + if (item.name) { + overriddenNames.add(item.name); + } + } + } + + // Append authoritative built-in entries (excluding any that have been + // overridden by a workspace or user copy with the same name). + const uriUseCounts = new ResourceMap(); + for (const item of deduped) { + uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); + } + const appended: IAICustomizationListItem[] = []; + const disabledPromptFiles = this.promptsService.getDisabledPromptFiles(PromptsType.skill); + for (const p of builtinPaths) { + const name = p.name ?? basename(p.uri); + if (overriddenNames.has(name)) { + continue; + } + const builtinItem: ICustomizationItem = { + uri: p.uri, + type: PromptsType.skill, + name, + description: p.description, + source: AICustomizationSources.builtin, + groupKey: BUILTIN_STORAGE, + enabled: !disabledPromptFiles.has(p.uri), + extensionId: undefined, + pluginUri: undefined, + userInvocable: true, + }; + appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); + } + + return [...deduped, ...appended]; + } + private async addSkillDescriptionFallbacks(items: readonly ICustomizationItem[]): Promise { const descriptionsByUri = new Map(); const skills = await this.promptsService.findAgentSkills(CancellationToken.None); @@ -471,13 +455,9 @@ export class PureItemProviderItemSource extends Disposable implements IAICustomi readonly sessionResource: URI, private readonly itemProvider: ICustomizationItemProvider, private readonly itemNormalizer: AICustomizationItemNormalizer, - private readonly promptsService: IPromptsService, - private readonly workspaceService: IAICustomizationWorkspaceService, ) { super(); - // Built-in skills are merged in from the prompts service, so their - // enable/disable state changes must refresh the list too. - this.onDidAICustomizationItemsChange = Event.any(this.itemProvider.onDidChange, this.promptsService.onDidChangeSkills); + this.onDidAICustomizationItemsChange = this.itemProvider.onDidChange; // Invalidate cache when the provider changes this._register(this.itemProvider.onDidChange(() => { @@ -505,11 +485,7 @@ export class PureItemProviderItemSource extends Disposable implements IAICustomi async fetchAICustomizationItems(promptType: PromptsType): Promise { const allItems = await this.fetchProviderItems(); - const normalized = this.itemNormalizer.normalizeItems(allItems, promptType); - if (promptType === PromptsType.skill) { - return mergeBuiltinSkills(normalized, promptType, this.promptsService, this.workspaceService, this.itemNormalizer); - } - return normalized; + return this.itemNormalizer.normalizeItems(allItems, promptType); } async fetchSourceFolders(promptType: PromptsType): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index 39be8a564aa34c..4e55a61d842827 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -257,7 +257,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz this.logService.warn(`Agent-host session type ${sessionType} has no item provider`); return new EmptyItemProviderItemSource(sessionResource); } - return new PureItemProviderItemSource(sessionResource, descriptor.itemProvider, this.itemNormalizer, this.promptsService, this.workspaceService); + return new PureItemProviderItemSource(sessionResource, descriptor.itemProvider, this.itemNormalizer); } else { const itemProvider = descriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider); return new ItemProviderItemSource( diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 164c6b6bfd7a0a..4463f828e74eac 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -120,16 +120,6 @@ export enum PromptsStorage { builtIn = 'builtin', } -/** - * Whether the AI Customizations UI offers Enable/Disable affordances for a - * customization with the given type and storage — and therefore whether a user - * who disables it can turn it back on again. - * - * Gate any behaviour that *hides* a customization because it is in - * {@link IPromptsService.getDisabledPromptFiles} on this predicate; that store - * is shared with surfaces that own a separate unhide affordance. See - * "Enabling and Disabling Built-in Skills" in `src/vs/sessions/AI_CUSTOMIZATIONS.md`. - */ export function isUserToggleableCustomization(type: PromptsType, storage: PromptsStorage): boolean { return type === PromptsType.skill && storage === PromptsStorage.builtIn; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts index fc64462616f040..260e3a992d4f4a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts @@ -6,6 +6,8 @@ import assert from 'assert'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; @@ -20,8 +22,17 @@ import { AgentCustomizationItemProvider } from '../../../browser/agentSessions/a import { NullAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; +function makePromptsService(): IPromptsService { + return upcastPartial({ + onDidChangeSkills: Event.None, + getDisabledPromptFiles: () => new ResourceSet(), + listPromptFilesForStorage: async () => [], + }); +} + suite('AgentCustomizationItemProvider', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -60,6 +71,7 @@ suite('AgentCustomizationItemProvider', () => { fileService, new NullLogService(), new TestCustomizationService(), + makePromptsService(), )); provider.setDraftCustomizations(observableValue('draftCustomizations', [{ type: CustomizationType.Plugin, @@ -113,6 +125,7 @@ suite('AgentCustomizationItemProvider', () => { upcastPartial({}), new NullLogService(), new TestCustomizationService(), + makePromptsService(), )); const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); @@ -132,6 +145,58 @@ suite('AgentCustomizationItemProvider', () => { }]); }); + test('overrides a stale enabled provider row when its built-in skill is user-disabled', async () => { + const bundleUri = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/bundle' }); + const bundledSkillUri = URI.joinPath(bundleUri, 'skills', 'create-pr', 'SKILL.md'); + const builtinSkillUri = URI.file('/builtin/create-pr/SKILL.md'); + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(SYNCED_CUSTOMIZATION_SCHEME, disposables.add(new InMemoryFileSystemProvider()))); + await fileService.writeFile(bundledSkillUri, VSBuffer.fromString('---\nname: create-pr\ndescription: Create a pull request.\n---\nCreate it.')); + const promptsService = upcastPartial({ + onDidChangeSkills: Event.None, + getDisabledPromptFiles: () => new ResourceSet([builtinSkillUri]), + listPromptFilesForStorage: async () => [{ + uri: builtinSkillUri, + type: PromptsType.skill, + storage: PromptsStorage.builtIn, + name: 'create-pr', + description: 'Create a pull request.', + } as IPromptPath], + }); + const provider = disposables.add(new AgentCustomizationItemProvider( + 'local', + undefined, + syncedUri => syncedUri.toString() === bundledSkillUri.toString() + ? { uri: builtinSkillUri, source: AICustomizationSources.builtin } + : undefined, + fileService, + new NullLogService(), + new NullAgentHostCustomizationService(), + promptsService, + )); + provider.setDraftCustomizations(observableValue('draftCustomizations', [{ + type: CustomizationType.Plugin, + id: bundleUri.toString(), + uri: bundleUri.toString(), + name: 'VS Code Synced Data', + nonce: '1', + }])); + + const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///draft'), CancellationToken.None); + + assert.deepStrictEqual(items.map(item => ({ + uri: item.uri.toString(), + type: item.type, + source: item.source, + enabled: item.enabled, + })), [{ + uri: builtinSkillUri.toString(), + type: PromptsType.skill, + source: AICustomizationSources.builtin, + enabled: false, + }]); + }); + test('surfaces only the host-published winning disabled reason', async () => { const customizations: PluginCustomization[] = [ { @@ -164,6 +229,7 @@ suite('AgentCustomizationItemProvider', () => { upcastPartial({}), new NullLogService(), new TestCustomizationService(), + makePromptsService(), )); const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); @@ -184,4 +250,55 @@ suite('AgentCustomizationItemProvider', () => { }, ]); }); + + test('supplements provider output with user-disabled built-in skills', async () => { + const disabledSkill = URI.file('/builtin/create-pr/SKILL.md'); + let disabledPromptFiles = new ResourceSet([disabledSkill]); + const onDidChangeSkills = disposables.add(new Emitter()); + const promptsService = upcastPartial({ + onDidChangeSkills: onDidChangeSkills.event, + getDisabledPromptFiles: () => disabledPromptFiles, + listPromptFilesForStorage: async (type: PromptsType, storage: PromptsStorage) => type === PromptsType.skill && storage === PromptsStorage.builtIn + ? [{ uri: disabledSkill, type, storage, name: 'create-pr', description: 'Create a pull request.' } satisfies IPromptPath] + : [], + }); + const provider = disposables.add(new AgentCustomizationItemProvider( + 'local', + undefined, + undefined, + upcastPartial({}), + new NullLogService(), + new NullAgentHostCustomizationService(), + promptsService, + )); + let changeCount = 0; + disposables.add(provider.onDidChange(() => changeCount++)); + + const disabledItems = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); + disabledPromptFiles = new ResourceSet(); + onDidChangeSkills.fire(); + const enabledItems = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); + + assert.deepStrictEqual({ + disabledItems: disabledItems.map(item => ({ + uri: item.uri.toString(), + type: item.type, + name: item.name, + source: item.source, + enabled: item.enabled, + })), + changeCount, + enabledItems, + }, { + disabledItems: [{ + uri: disabledSkill.toString(), + type: PromptsType.skill, + name: 'create-pr', + source: AICustomizationSources.builtin, + enabled: false, + }], + changeCount: 1, + enabledItems: [], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts index b1973ba3ebf5d0..c9527532bc89e0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts @@ -95,6 +95,28 @@ suite('enumerateLocalCustomizationsForHarness', () => { assert.strictEqual(result[0].disabled, true); }); + test('honors user enablement only for built-in skills', async () => { + const builtinSkill = URI.file('/builtin/create-pr/SKILL.md'); + const extensionAgent = URI.file('/extension/agents/reviewer.agent.md'); + const promptsService = makePromptsService( + new Map([ + [`${PromptsType.skill}/${BUILTIN_STORAGE}`, [makePromptPath(builtinSkill, PromptsType.skill, PromptsStorage.builtIn)]], + [`${PromptsType.agent}/${PromptsStorage.extension}`, [makePromptPath(extensionAgent, PromptsType.agent, PromptsStorage.extension)]], + ]), + new Map([ + [PromptsType.skill, new ResourceSet([builtinSkill])], + [PromptsType.agent, new ResourceSet([extensionAgent])], + ]), + ); + + const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None, undefined); + + assert.deepStrictEqual(result.map(item => ({ uri: item.uri.toString(), disabled: item.disabled })), [ + { uri: extensionAgent.toString(), disabled: false }, + { uri: builtinSkill.toString(), disabled: true }, + ]); + }); + test('includes all user prompt types only when user storage is enabled', async () => { const userAgent = URI.file('/home/user/.copilot/agents/user.agent.md'); const userSkill = URI.file('/home/user/.claude/skills/user-skill/SKILL.md'); @@ -148,54 +170,6 @@ suite('enumerateLocalCustomizationsForHarness', () => { ]); }); - test('marks built-in skills disabled when the user disabled them in the Customizations UI', async () => { - // The Enable/Disable actions write to `IPromptsService`, not to the - // per-harness sync provider. Both stores must be honored, otherwise a - // disabled built-in skill would still be synced to the agent host. - const disabledSkill = URI.file('/builtin/create-pr/SKILL.md'); - const enabledSkill = URI.file('/builtin/merge/SKILL.md'); - const promptsService = makePromptsService( - new Map([ - [`${PromptsType.skill}/${BUILTIN_STORAGE}`, [ - makePromptPath(disabledSkill, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), - makePromptPath(enabledSkill, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), - ]], - ]), - new Map([[PromptsType.skill, new ResourceSet([disabledSkill])]]), - ); - - const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None, undefined); - - assert.deepStrictEqual(result.map(item => ({ uri: item.uri.toString(), disabled: item.disabled })), [ - { uri: disabledSkill.toString(), disabled: true }, - { uri: enabledSkill.toString(), disabled: false }, - ]); - }); - - test('does not honor the user-disabled store for prompt types the Customizations UI cannot re-enable', async () => { - // `getDisabledPromptFiles(agent)` is also written by the chat view agent - // picker ("hidden from agent picker"). The Customizations UI registers - // Enable/Disable only for built-in skills, so it has no way to bring a - // hidden agent back. Dropping it from the bundle would remove it from the - // Agents-window list too, stranding it permanently — so the wire must - // ignore that store here and leave the agent enabled. - const hiddenAgent = URI.file('/extension/agents/reviewer.agent.md'); - const promptsService = makePromptsService( - new Map([ - [`${PromptsType.agent}/${PromptsStorage.extension}`, [ - makePromptPath(hiddenAgent, PromptsType.agent, PromptsStorage.extension), - ]], - ]), - new Map([[PromptsType.agent, new ResourceSet([hiddenAgent])]]), - ); - - const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None, undefined); - - assert.deepStrictEqual(result.map(item => ({ uri: item.uri.toString(), disabled: item.disabled })), [ - { uri: hiddenAgent.toString(), disabled: false }, - ]); - }); - test('returns empty when the prompts service exposes no built-in skills (regular workbench)', async () => { // The regular workbench's PromptsServiceImpl treats `builtin` as a // first-class storage that simply yields no files (rather than diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index dbd5ae2d5952dd..f87a74a02303f7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -7,8 +7,8 @@ import assert from 'assert'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PluginFormat } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -258,17 +258,14 @@ suite('resolveCustomizationRefs - built-in skills', () => { assert.deepStrictEqual(bundler.received[0].map(f => f.uri.toString()), [enabled.toString()]); }); - test('omits built-in skills the user disabled in the Customizations UI from the bundle', async () => { - // Regression: the Enable/Disable actions write to `IPromptsService`, - // not to the per-harness sync provider, so a skill disabled from the UI - // must still be dropped from the bundle sent to the agent host. + test('omits user-disabled built-in skills from the bundle', async () => { const enabled = URI.file('/builtin/create-pr/SKILL.md'); const disabled = URI.file('/builtin/merge/SKILL.md'); const promptsService = makePromptsService( new Map([ [`${PromptsType.skill}/${BUILTIN_STORAGE}`, [ - makePromptPath(enabled, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), - makePromptPath(disabled, PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage), + makePromptPath(enabled, PromptsType.skill, PromptsStorage.builtIn), + makePromptPath(disabled, PromptsType.skill, PromptsStorage.builtIn), ]], ]), new Map([[PromptsType.skill, new ResourceSet([disabled])]]), @@ -287,7 +284,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { undefined, ); - assert.deepStrictEqual(bundler.received[0].map(f => f.uri.toString()), [enabled.toString()]); + assert.deepStrictEqual(bundler.received[0].map(file => file.uri.toString()), [enabled.toString()]); }); test('combines built-in skills with user files in a single bundle', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 9db7ad037680db..2ad3ed2fb964c5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -806,16 +806,10 @@ suite('AICustomizationItemsModel', () => { let disposables: DisposableStore; let instaService: TestInstantiationService; let providerItems: ICustomizationItem[]; - let builtinSkills: IPromptPath[]; - let disabledPromptFiles: ResourceSet; - let onDidChangeSkills: Emitter; setup(() => { disposables = new DisposableStore(); providerItems = []; - builtinSkills = []; - disabledPromptFiles = new ResourceSet(); - onDidChangeSkills = disposables.add(new Emitter()); const sessionType = 'agent-host-test'; const provider: ICustomizationItemProvider = { @@ -835,19 +829,17 @@ suite('AICustomizationItemsModel', () => { instaService.stub(IPromptsService, { onDidChangeCustomAgents: Event.None, onDidChangeSlashCommands: Event.None, - onDidChangeSkills: onDidChangeSkills.event, + onDidChangeSkills: Event.None, onDidChangeHooks: Event.None, onDidChangeInstructions: Event.None, onDidChangeAgentInstructions: Event.None, listPromptFiles: async () => [], - listPromptFilesForStorage: async (type: PromptsType, storage: PromptsStorage) => ( - type === PromptsType.skill && storage === PromptsStorage.builtIn ? builtinSkills.slice() : [] - ), + listPromptFilesForStorage: async () => [], getCustomAgents: async () => [], findAgentSkills: async () => [], getHooks: async () => undefined, getInstructionFiles: async () => [], - getDisabledPromptFiles: () => disabledPromptFiles, + getDisabledPromptFiles: () => new ResourceSet(), }); instaService.stub(IAICustomizationWorkspaceService, { activeProjectRoot: observableValue('test', undefined), @@ -915,73 +907,5 @@ suite('AICustomizationItemsModel', () => { }, ); }); - - // Regression: on agent-host harnesses the Skills list used to render - // straight from the provider, which reports the synced *bundle*. A - // built-in skill disabled from the Customizations UI is dropped from - // that bundle, so the skill vanished from the list instead of showing - // as disabled — leaving no way to re-enable it and making the Disable - // button look like a no-op. Built-ins are now merged in from the - // prompts service, which owns the enable/disable state. - test('lists disabled built-in skills as disabled instead of dropping them', async () => { - const disabledSkill = URI.file('/builtin/create-pr/SKILL.md'); - const enabledSkill = URI.file('/builtin/merge/SKILL.md'); - builtinSkills = [ - { uri: disabledSkill, type: PromptsType.skill, storage: PromptsStorage.builtIn, name: 'create-pr' } as IPromptPath, - { uri: enabledSkill, type: PromptsType.skill, storage: PromptsStorage.builtIn, name: 'merge' } as IPromptPath, - ]; - disabledPromptFiles = new ResourceSet([disabledSkill]); - // The provider only reports the still-bundled skill; the disabled - // one is absent because it was excluded from the synced bundle. - providerItems = [ - { uri: enabledSkill, type: PromptsType.skill, name: 'merge', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, extensionId: undefined, pluginUri: undefined, userInvocable: true }, - ]; - - const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); - const skillItems = model.getItems(AICustomizationManagementSection.Skills); - await model.whenSectionLoaded(AICustomizationManagementSection.Skills); - - assert.deepStrictEqual( - skillItems.get().map(i => ({ name: i.name, source: i.source, groupKey: i.groupKey, disabled: i.disabled })).sort((a, b) => a.name.localeCompare(b.name)), - [ - { name: 'create-pr', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, disabled: true }, - { name: 'merge', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, disabled: false }, - ], - ); - }); - test('refreshes built-in skill disabled state when onDidChangeSkills fires', async () => { - // The Disable action writes to IPromptsService and fires - // onDidChangeSkills; the provider is unchanged (its bundle refresh is - // asynchronous and may lag). PureItemProviderItemSource must still - // re-derive `disabled` from the prompts service, otherwise the row - // would stay stale until some unrelated provider change happened. - const skill = URI.file('/builtin/create-pr/SKILL.md'); - builtinSkills = [ - { uri: skill, type: PromptsType.skill, storage: PromptsStorage.builtIn, name: 'create-pr' } as IPromptPath, - ]; - providerItems = [ - { uri: skill, type: PromptsType.skill, name: 'create-pr', source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, extensionId: undefined, pluginUri: undefined, userInvocable: true }, - ]; - - const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); - const skillItems = model.getItems(AICustomizationManagementSection.Skills); - await model.whenSectionLoaded(AICustomizationManagementSection.Skills); - // Let any refetch scheduled during construction settle, so the - // assertion below can only be satisfied by a refetch that the - // onDidChangeSkills subscription itself triggered. - await timeout(0); - assert.deepStrictEqual(skillItems.get().map(i => ({ name: i.name, disabled: i.disabled })), [ - { name: 'create-pr', disabled: false }, - ]); - - disabledPromptFiles = new ResourceSet([skill]); - onDidChangeSkills.fire(); - await timeout(0); - await model.whenSectionLoaded(AICustomizationManagementSection.Skills); - - assert.deepStrictEqual(skillItems.get().map(i => ({ name: i.name, disabled: i.disabled })), [ - { name: 'create-pr', disabled: 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 3adcf877df2673..d282f82685562e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -178,7 +178,9 @@ function createFixtureAgentHostItemProvider(files: readonly IFixtureFile[], remo name: file.name ?? '', description: file.description, source: file.storage as AICustomizationSource, - groupKey: file.type === PromptsType.skill && file.name === remoteClientSkillName ? 'remote-client' : 'remote-host', + groupKey: file.storage === PromptsStorage.builtIn + ? undefined + : file.type === PromptsType.skill && file.name === remoteClientSkillName ? 'remote-client' : 'remote-host', extensionId: file.extensionId, pluginUri: undefined, })); @@ -1888,7 +1890,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { render: ctx => renderEditor(ctx, { sessionResource: agentHostCopilotSessionResource, selectedSection: AICustomizationManagementSection.Skills, - agentHostFiles: allFiles.filter(file => file.type !== PromptsType.skill || file.name === 'Accessibility' || file.name === 'Code Review'), + agentHostFiles: allFiles.filter(file => file.type !== PromptsType.skill || file.name === 'Accessibility' || file.name === 'Code Review' || file.storage === PromptsStorage.builtIn), remoteClientSkillName: 'Code Review', height: 800, }), From aea8984f0e0d913c97c95ca56d6666c79ef558cd Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:46:33 -0700 Subject: [PATCH 18/41] Browser: agent-scoped storage (#333694) * Browser: agent-scoped storage * feedback * update test --- .../singlefolder-tests/browser.tools.test.ts | 126 ++++++++++++++++++ .../browserView/common/browserView.ts | 20 ++- .../browserView/common/playwrightService.ts | 2 + .../electron-main/browserSession.ts | 30 +++-- .../electron-main/browserSessionHistory.ts | 4 +- .../browserSessionPermissions.ts | 4 +- .../electron-main/browserSessionTrust.ts | 6 +- .../electron-main/browserViewGroup.ts | 18 ++- .../electron-main/browserViewMainService.ts | 3 +- .../browserView/node/playwrightChannel.ts | 7 +- .../browserView/node/playwrightService.ts | 3 +- .../test/common/browserView.test.ts | 37 ++++- .../electron-main/browserSessionTrust.test.ts | 51 ++++++- .../contrib/browserView/common/browserView.ts | 21 +-- .../browserView/common/browserZoomService.ts | 78 +++++------ .../features/browserDataStorageFeatures.ts | 35 ++++- .../features/browserHistoryFeature.ts | 6 +- .../electron-browser/tools/openBrowserTool.ts | 4 +- .../tools/openBrowserTool.test.ts | 5 +- .../playwrightWorkbenchService.ts | 3 + 20 files changed, 370 insertions(+), 93 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts index def6d45fd1096f..cefcef5a2ed2bb 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import * as http from 'http'; import * as path from 'path'; import 'mocha'; import * as vscode from 'vscode'; @@ -90,6 +91,131 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.match(output, /Page ID:/, `Expected output to contain "Page ID:", got: ${output}`); }); + (vscode.env.remoteName ? test.skip : test)('Agent storage is shared between API and tool pages and isolated from persistent storage', async function () { + this.timeout(60_000); + + const token = `${Date.now()}-${Math.random()}`; + let agentReceivedCookie: string | undefined; + let globalReceivedCookie: string | undefined; + let workspaceReceivedCookie: string | undefined; + const server = http.createServer((request, response) => { + if (request.url === '/set-global') { + response.setHeader('Set-Cookie', `vscode-browser-global-smoke=${token}; Path=/; SameSite=Lax`); + response.end('global-cookie-set'); + return; + } + + if (request.url === '/set-workspace') { + response.setHeader('Set-Cookie', `vscode-browser-workspace-smoke=${token}; Path=/; SameSite=Lax`); + response.end('workspace-cookie-set'); + return; + } + + if (request.url === '/set-agent') { + response.setHeader('Set-Cookie', `vscode-browser-agent-smoke=${token}; Path=/; SameSite=Lax`); + response.end('agent-cookie-set'); + return; + } + + if (request.url === '/check-agent') { + agentReceivedCookie = request.headers.cookie; + response.end('agent-cookie-checked'); + return; + } + + if (request.url === '/check-global') { + globalReceivedCookie = request.headers.cookie; + response.end('global-cookie-checked'); + return; + } + + if (request.url === '/check-workspace') { + workspaceReceivedCookie = request.headers.cookie; + response.end('workspace-cookie-checked'); + return; + } + + response.end('unexpected-request'); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const browserConfig = vscode.workspace.getConfiguration('workbench.browser'); + + try { + await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); + const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-global`); + for (let i = 0; i < 100 && !globalSetTab.title.startsWith('global-cookie-set'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(globalSetTab.title.startsWith('global-cookie-set'), `Expected Global page to load, got title "${globalSetTab.title}"`); + + await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); + const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-workspace`); + for (let i = 0; i < 100 && !workspaceSetTab.title.startsWith('workspace-cookie-set'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(workspaceSetTab.title.startsWith('workspace-cookie-set'), `Expected Workspace page to load, got title "${workspaceSetTab.title}"`); + + await browserConfig.update('dataStorage', 'agent', vscode.ConfigurationTarget.Global); + const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-agent`); + + for (let i = 0; i < 100 && !agentSetTab.title.startsWith('agent-cookie-set'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(agentSetTab.title.startsWith('agent-cookie-set'), `Expected Agent page to load, got title "${agentSetTab.title}"`); + + const output = await invokeTool('open_browser_page', { + url: `http://127.0.0.1:${address.port}/check-agent`, + forceNew: true, + }); + + await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global); + const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-global`); + for (let i = 0; i < 100 && !globalCheckTab.title.startsWith('global-cookie-checked'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + + await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global); + const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-workspace`); + for (let i = 0; i < 100 && !workspaceCheckTab.title.startsWith('workspace-cookie-checked'); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + + assert.deepStrictEqual({ + opened: /Page ID:/.test(output), + agentSharedCookie: agentReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + agentReceivedGlobalCookie: agentReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, + agentReceivedWorkspaceCookie: agentReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, + globalLoaded: globalCheckTab.title.startsWith('global-cookie-checked'), + globalSharedCookie: globalReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true, + globalReceivedAgentCookie: globalReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + workspaceLoaded: workspaceCheckTab.title.startsWith('workspace-cookie-checked'), + workspaceSharedCookie: workspaceReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true, + workspaceReceivedAgentCookie: workspaceReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true, + }, { + opened: true, + agentSharedCookie: true, + agentReceivedGlobalCookie: false, + agentReceivedWorkspaceCookie: false, + globalLoaded: true, + globalSharedCookie: true, + globalReceivedAgentCookie: false, + workspaceLoaded: true, + workspaceSharedCookie: true, + workspaceReceivedAgentCookie: false, + }); + } finally { + await browserConfig.update('dataStorage', undefined, vscode.ConfigurationTarget.Global); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + }); + test('list_browser_pages returns pages opened through the browser tools', async function () { this.timeout(60000); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 12dba9d4fb1d68..f11d0c4ee4c6b9 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -58,6 +58,7 @@ export enum BrowserViewCommandId { ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, + ClearAgentStorage = `${commandPrefix}.clearAgentStorage`, // Find in page ShowFind = `${commandPrefix}.showFind`, @@ -431,29 +432,34 @@ export interface IBrowserViewFindInPageResult { export enum BrowserViewStorageScope { Global = 'global', Workspace = 'workspace', - Ephemeral = 'ephemeral' + Ephemeral = 'ephemeral', + Agent = 'agent' } export type IBrowserViewSessionOptions = | { readonly scope: BrowserViewStorageScope.Global } | { readonly scope: BrowserViewStorageScope.Workspace } + | { readonly scope: BrowserViewStorageScope.Ephemeral } | { - readonly scope: BrowserViewStorageScope.Ephemeral; + readonly scope: BrowserViewStorageScope.Agent; /** Views with the same affinity share one in-memory browser session. */ readonly affinity?: string; }; +export function isInMemoryStorageScope(scope: BrowserViewStorageScope): boolean { + return scope === BrowserViewStorageScope.Ephemeral || scope === BrowserViewStorageScope.Agent; +} + /** Selects an existing browser context by ID or resolves one from storage options. */ export type BrowserViewSessionSelector = string | IBrowserViewSessionOptions; -export function getAgentBrowserViewCreationDefaults(sessionId: string) { +export function getAgentBrowserViewCreationDefaults(sessionId: string, storageAffinity?: string) { return { owner: { type: 'agent', sessionId } as const, initialAudiences: [{ type: 'agent' }] as const, - session: { - scope: BrowserViewStorageScope.Ephemeral, - affinity: sessionId - } as const + session: storageAffinity === undefined + ? { scope: BrowserViewStorageScope.Agent } as const + : { scope: BrowserViewStorageScope.Agent, affinity: storageAffinity } as const }; } diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 565a496a8ec643..a179fdd4dda57d 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -12,6 +12,8 @@ export const IPlaywrightService = createDecorator('playwrigh */ export interface IPlaywrightServiceInitializeOptions { readonly windowId: number; + /** Whether Playwright groups isolate Agent storage by their session ID. */ + readonly useSessionStorageAffinity: boolean; } export interface IInvokeFunctionResult { diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index bf4eae2f6a4a9a..9184d834da4968 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -57,7 +57,7 @@ export class BrowserSession { * - Global scope -> `"global"` * - Workspace scope -> `"workspace:${workspaceId}"` * - Ephemeral per-view -> `"ephemeral:${viewId}"` - * - Ephemeral affinity -> `"ephemeral-affinity:${affinityHash}"` + * - Agent scope -> `"agent:${identityHash}"` * - Custom type -> `"${type}:${viewId}"` */ private static readonly _byId = new Map>(); @@ -138,7 +138,7 @@ export class BrowserSession { * Get or create an ephemeral session for the given view or target ID. */ static getOrCreateEphemeral(instantiationService: IInstantiationService, viewId: string, type?: string): BrowserSession { - if (type === 'workspace' || type === 'ephemeral') { + if (type === 'workspace' || type === 'ephemeral' || type === 'agent') { throw new Error(`Cannot create session with reserved type '${type}'`); } @@ -148,11 +148,22 @@ export class BrowserSession { ?? instantiationService.createInstance(BrowserSession, sessionId, electronSession, BrowserViewStorageScope.Ephemeral); } - private static getOrCreateEphemeralForAffinity(instantiationService: IInstantiationService, affinity: string): BrowserSession { - const affinityHash = createHash('sha256').update(affinity).digest('hex'); - const electronSession = session.fromPartition(`vscode-browser-affinity-${affinityHash}`); + /** Get or create an in-memory agent session by affinity, workspace, or window. */ + static getOrCreateAgent(instantiationService: IInstantiationService, workspaceId: string | undefined, affinity?: string, windowId?: number): BrowserSession { + let identity: string; + if (affinity !== undefined) { + identity = `affinity:${affinity}`; + } else if (workspaceId !== undefined) { + identity = `workspace:${workspaceId}`; + } else if (windowId !== undefined) { + identity = `window:${windowId}`; + } else { + throw new Error('Agent browser sessions require an affinity, workspace, or window'); + } + const identityHash = createHash('sha256').update(identity).digest('hex'); + const electronSession = session.fromPartition(`vscode-browser-agent-${identityHash}`); return BrowserSession._bySession.get(electronSession) - ?? instantiationService.createInstance(BrowserSession, `ephemeral-affinity:${affinityHash}`, electronSession, BrowserViewStorageScope.Ephemeral); + ?? instantiationService.createInstance(BrowserSession, `agent:${identityHash}`, electronSession, BrowserViewStorageScope.Agent); } /** @@ -177,6 +188,7 @@ export class BrowserSession { options: IBrowserViewSessionOptions, workspaceStorageHome: URI, workspaceId?: string, + windowId?: number, ): BrowserSession { switch (options.scope) { case BrowserViewStorageScope.Global: @@ -187,9 +199,9 @@ export class BrowserSession { } return BrowserSession.getOrCreateEphemeral(instantiationService, viewId); case BrowserViewStorageScope.Ephemeral: - return options.affinity !== undefined - ? BrowserSession.getOrCreateEphemeralForAffinity(instantiationService, options.affinity) - : BrowserSession.getOrCreateEphemeral(instantiationService, viewId); + return BrowserSession.getOrCreateEphemeral(instantiationService, viewId); + case BrowserViewStorageScope.Agent: + return BrowserSession.getOrCreateAgent(instantiationService, workspaceId, options.affinity, windowId); } } diff --git a/src/vs/platform/browserView/electron-main/browserSessionHistory.ts b/src/vs/platform/browserView/electron-main/browserSessionHistory.ts index 94a649a98f27ab..3887b293770359 100644 --- a/src/vs/platform/browserView/electron-main/browserSessionHistory.ts +++ b/src/vs/platform/browserView/electron-main/browserSessionHistory.ts @@ -13,7 +13,7 @@ import { ISerializedBrowserFaviconsSnapshot, ISerializedBrowserHistoryEntriesSnapshot, } from '../common/browserHistory.js'; -import { BrowserViewStorageScope, IBrowserViewStorageKeys } from '../common/browserView.js'; +import { IBrowserViewStorageKeys, isInMemoryStorageScope } from '../common/browserView.js'; import type { BrowserSession } from './browserSession.js'; const FLUSH_INTERVAL_MS = 2000; @@ -54,7 +54,7 @@ export class BrowserSessionHistory extends Disposable implements IBrowserSession constructor(session: BrowserSession) { super(); - this.storageKeys = session.storageScope === BrowserViewStorageScope.Ephemeral + this.storageKeys = isInMemoryStorageScope(session.storageScope) ? {} : { history: `browser.history.entries.${session.id}`, diff --git a/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts index a30b3b3d5edd1d..a30091a1be2e71 100644 --- a/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts +++ b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts @@ -22,7 +22,7 @@ import { isAlwaysAllowedPermission, toOriginKey, } from '../common/browserPermissions.js'; -import { BrowserViewStorageScope, IBrowserViewPermissionRequestEvent, IBrowserViewStorageKeys } from '../common/browserView.js'; +import { IBrowserViewPermissionRequestEvent, IBrowserViewStorageKeys, isInMemoryStorageScope } from '../common/browserView.js'; import type { BrowserSession } from './browserSession.js'; /** Time the main process waits for a prompt answer before a non-persisted deny. */ @@ -146,7 +146,7 @@ export class BrowserSessionPermissions extends Disposable implements IBrowserSes constructor(session: BrowserSession) { super(); - this.storageKeys = session.storageScope === BrowserViewStorageScope.Ephemeral + this.storageKeys = isInMemoryStorageScope(session.storageScope) ? {} : { permissions: `browser.permissions.${session.id}` }; diff --git a/src/vs/platform/browserView/electron-main/browserSessionTrust.ts b/src/vs/platform/browserView/electron-main/browserSessionTrust.ts index 01c82a072bcadd..95ed065c84e8e6 100644 --- a/src/vs/platform/browserView/electron-main/browserSessionTrust.ts +++ b/src/vs/platform/browserView/electron-main/browserSessionTrust.ts @@ -5,7 +5,7 @@ import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; -import { IBrowserViewCertificateError } from '../common/browserView.js'; +import { IBrowserViewCertificateError, isInMemoryStorageScope } from '../common/browserView.js'; import type { BrowserSession } from './browserSession.js'; /** Key used to store trusted certificate data in the application storage. */ @@ -201,8 +201,8 @@ export class BrowserSessionTrust implements IBrowserSessionTrust { * first call; subsequent calls are no-ops. */ connectStorage(storage: IApplicationStorageMainService): void { - if (this._storage) { - return; // already connected + if (this._storage || isInMemoryStorageScope(this._session.storageScope)) { + return; } this._storage = storage; this.readStorage(); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index 9808c0edaea109..b235c432f59c51 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -9,7 +9,7 @@ import { BrowserView } from './browserView.js'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IBrowserViewGroup, IBrowserViewGroupFilter, matchesBrowserViewGroupFilter } from '../common/browserViewGroup.js'; -import { IBrowserViewCreationContext } from '../common/browserView.js'; +import { BrowserViewStorageScope, IBrowserViewCreationContext } from '../common/browserView.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { BrowserSession } from './browserSession.js'; @@ -317,11 +317,17 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } async createBrowserContext(): Promise { - const browserSession = BrowserSession.getOrCreateEphemeral(this.instantiationService, generateUuid(), 'cdp-created'); - const contextId = browserSession.id; - this.knownContextIds.add(contextId); - this.ownedContextIds.add(contextId); - return contextId; + const contextId = generateUuid(); + const sessionSelector = this.targetContext.session; + const usesAgentStorage = typeof sessionSelector === 'string' + ? BrowserSession.get(sessionSelector)?.storageScope === BrowserViewStorageScope.Agent + : sessionSelector.scope === BrowserViewStorageScope.Agent; + const browserSession = usesAgentStorage + ? BrowserSession.getOrCreateAgent(this.instantiationService, undefined, contextId) + : BrowserSession.getOrCreateEphemeral(this.instantiationService, contextId, 'cdp-created'); + this.knownContextIds.add(browserSession.id); + this.ownedContextIds.add(browserSession.id); + return browserSession.id; } async disposeBrowserContext(browserContextId: string): Promise { diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 7de2bb5cd3ea0a..a3bae4f47dc273 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -109,7 +109,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa id, selector, this.environmentMainService.workspaceStorageHome, - hostWindow.openedWorkspace?.id + hostWindow.openedWorkspace?.id, + hostWindowId ); } diff --git a/src/vs/platform/browserView/node/playwrightChannel.ts b/src/vs/platform/browserView/node/playwrightChannel.ts index f0cd0bc8ea0d94..a22d98bccb3986 100644 --- a/src/vs/platform/browserView/node/playwrightChannel.ts +++ b/src/vs/platform/browserView/node/playwrightChannel.ts @@ -58,10 +58,10 @@ export class PlaywrightChannel extends Disposable implements IServerChannel; - return typeof candidate.windowId === 'number'; + return typeof candidate.windowId === 'number' + && typeof candidate.useSessionStorageAffinity === 'boolean'; } diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 135069b93b3fb5..25fdd3ef5d3225 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -71,6 +71,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService constructor( private readonly windowId: number, + private readonly useSessionStorageAffinity: boolean, private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, private readonly logService: ILogService, private readonly agentNetworkFilterService: IAgentNetworkFilterService, @@ -117,7 +118,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService { audience: { type: 'agent', sessionId } }, { hostWindowId: this.windowId, - ...getAgentBrowserViewCreationDefaults(sessionId) + ...getAgentBrowserViewCreationDefaults(sessionId, this.useSessionStorageAffinity ? sessionId : undefined) } ); diff --git a/src/vs/platform/browserView/test/common/browserView.test.ts b/src/vs/platform/browserView/test/common/browserView.test.ts index ca4d1e0cfec29d..ed064c59023f7d 100644 --- a/src/vs/platform/browserView/test/common/browserView.test.ts +++ b/src/vs/platform/browserView/test/common/browserView.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience } from '../../common/browserView.js'; +import { BrowserViewStorageScope, getAgentBrowserViewCreationDefaults, isBrowserViewAssociatedResourceNavigation, isInMemoryStorageScope, matchesBrowserViewAudience } from '../../common/browserView.js'; suite('BrowserView', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -54,4 +54,39 @@ suite('BrowserView', () => { otherSession: false }); }); + + test('configures agent storage affinity independently from ownership', () => { + assert.deepStrictEqual({ + editorWindow: getAgentBrowserViewCreationDefaults('chat-session'), + agentsWindow: getAgentBrowserViewCreationDefaults('chat-session', 'chat-session'), + }, { + editorWindow: { + owner: { type: 'agent', sessionId: 'chat-session' }, + initialAudiences: [{ type: 'agent' }], + session: { scope: BrowserViewStorageScope.Agent } + }, + agentsWindow: { + owner: { type: 'agent', sessionId: 'chat-session' }, + initialAudiences: [{ type: 'agent' }], + session: { + scope: BrowserViewStorageScope.Agent, + affinity: 'chat-session' + } + } + }); + }); + + test('identifies in-memory storage scopes', () => { + assert.deepStrictEqual({ + global: isInMemoryStorageScope(BrowserViewStorageScope.Global), + workspace: isInMemoryStorageScope(BrowserViewStorageScope.Workspace), + ephemeral: isInMemoryStorageScope(BrowserViewStorageScope.Ephemeral), + agent: isInMemoryStorageScope(BrowserViewStorageScope.Agent), + }, { + global: false, + workspace: false, + ephemeral: true, + agent: true, + }); + }); }); diff --git a/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts b/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts index 47f07ccb6e99c0..e89fd6c5245dc0 100644 --- a/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts +++ b/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts @@ -9,6 +9,7 @@ import { EventEmitter } from 'events'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { StorageScope, StorageTarget } from '../../../storage/common/storage.js'; import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js'; +import { BrowserViewStorageScope } from '../../common/browserView.js'; import { BrowserSessionTrust } from '../../electron-main/browserSessionTrust.js'; import type { BrowserSession } from '../../electron-main/browserSession.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -38,6 +39,7 @@ class TestBrowserSession { constructor( readonly id: string, readonly electronSession: Electron.Session, + readonly storageScope: BrowserViewStorageScope, ) { } asBrowserSession(): BrowserSession { @@ -77,13 +79,13 @@ class TestWebContents extends EventEmitter { } } -function createTrust(sessionId = 'test-session'): { +function createTrust(sessionId = 'test-session', storageScope = BrowserViewStorageScope.Global): { trust: BrowserSessionTrust; electronSession: TestElectronSession; storage: TestApplicationStorageMainService; } { const electronSession = new TestElectronSession(); - const browserSession = new TestBrowserSession(sessionId, electronSession.asSession()); + const browserSession = new TestBrowserSession(sessionId, electronSession.asSession(), storageScope); const trust = new BrowserSessionTrust(browserSession.asBrowserSession()); const storage = new TestApplicationStorageMainService(); @@ -239,12 +241,53 @@ suite('BrowserSessionTrust', () => { assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'valid.example.com', fingerprint: 'valid' }]); })); + test('connectStorage ignores in-memory sessions', async () => { + const results = []; + for (const storageScope of [BrowserViewStorageScope.Ephemeral, BrowserViewStorageScope.Agent]) { + const { trust, storage } = createTrust('test-session', storageScope); + const persisted = JSON.stringify({ + 'test-session': { + trustedCerts: [{ host: 'persisted.example.com', fingerprint: 'persisted', expiresAt: Date.now() + 1000 }] + } + }); + storage.seed(STORAGE_KEY, persisted); + + trust.connectStorage(storage.asService()); + await trust.trustCertificate('memory.example.com', 'memory'); + + results.push({ + storageScope, + persistedTrustRestored: trust.isCertificateTrusted('persisted.example.com', 'persisted'), + inMemoryTrustAdded: trust.isCertificateTrusted('memory.example.com', 'memory'), + storageWrites: storage.store.callCount, + storageUnchanged: storage.read(STORAGE_KEY) === persisted, + }); + } + + assert.deepStrictEqual(results, [ + { + storageScope: BrowserViewStorageScope.Ephemeral, + persistedTrustRestored: false, + inMemoryTrustAdded: true, + storageWrites: 0, + storageUnchanged: true, + }, + { + storageScope: BrowserViewStorageScope.Agent, + persistedTrustRestored: false, + inMemoryTrustAdded: true, + storageWrites: 0, + storageUnchanged: true, + }, + ]); + }); + test('stored and reloaded trust expires and is pruned', async () => { const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); const storage = new TestApplicationStorageMainService(); const firstSession = new TestElectronSession(); - const firstBrowserSession = new TestBrowserSession('test-session', firstSession.asSession()); + const firstBrowserSession = new TestBrowserSession('test-session', firstSession.asSession(), BrowserViewStorageScope.Global); const firstTrust = new BrowserSessionTrust(firstBrowserSession.asBrowserSession()); firstTrust.connectStorage(storage.asService()); await firstTrust.trustCertificate('reload.example.com', 'reload-fingerprint'); @@ -252,7 +295,7 @@ suite('BrowserSessionTrust', () => { clock.tick(TRUST_DURATION_MS + 1); const secondSession = new TestElectronSession(); - const secondBrowserSession = new TestBrowserSession('test-session', secondSession.asSession()); + const secondBrowserSession = new TestBrowserSession('test-session', secondSession.asSession(), BrowserViewStorageScope.Global); const secondTrust = new BrowserSessionTrust(secondBrowserSession.asBrowserSession()); const webContents = new TestWebContents(); secondTrust.installCertErrorHandler(webContents.asWebContents()); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index ef6ae27f9cdda1..3e97adc005e158 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -37,6 +37,7 @@ import { IBrowserViewDevToolsStateEvent, IBrowserViewService, BrowserViewStorageScope, + isInMemoryStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, @@ -454,7 +455,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _certificateError: IBrowserViewCertificateError | undefined = undefined; private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral; private _isRemoteSession: boolean = false; - private _isEphemeral: boolean = false; + private _isInMemory: boolean = false; private _zoomHost: string | undefined = undefined; private _sharedWithAgent: boolean = false; private _browserZoomIndex: number = browserZoomDefaultIndex; @@ -516,7 +517,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._isAreaSelectionActive = initialState.isAreaSelectionActive; this._device = initialState.device; this._sharedWithAgent = initialState.audiences.some(audience => audience.type === 'agent'); - this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; + this._isInMemory = isInMemoryStorageScope(this._storageScope); this._zoomHost = parseZoomHost(this._url); const { history: entriesKey, favicons: faviconsKey } = initialState.storageKeys; @@ -540,7 +541,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { snapshot => this.permissions.hydrate(snapshot))); // Sync initial zoom - const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral); + const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isInMemory); if (effectiveZoomIndex !== this._browserZoomIndex) { void this.setBrowserZoomIndex(effectiveZoomIndex).catch(e => { this.logService.warn(`[BrowserViewModel] Failed to set initial zoom:`, e); @@ -548,13 +549,13 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } // Set up state synchronization - this._register(this.zoomService.onDidChangeZoom(({ host, isEphemeralChange }) => { - if (isEphemeralChange && !this._isEphemeral) { + this._register(this.zoomService.onDidChangeZoom(({ host, isInMemoryChange }) => { + if (isInMemoryChange && !this._isInMemory) { return; } if (host === undefined || host === this._zoomHost) { void this.setBrowserZoomIndex( - this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral) + this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isInMemory) ).catch(() => { }); } })); @@ -575,7 +576,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { // Always forceApply because Chromium resets zoom on cross-origin navigation, // and an origin change may not correspond to a host change (e.g. http→https). void this.setBrowserZoomIndex( - this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral), + this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isInMemory), true ); })); @@ -836,7 +837,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } await this.setBrowserZoomIndex(this._browserZoomIndex + 1); if (this._zoomHost) { - this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isEphemeral); + this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isInMemory); } } @@ -846,7 +847,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } await this.setBrowserZoomIndex(this._browserZoomIndex - 1); if (this._zoomHost) { - this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isEphemeral); + this.zoomService.setHostZoomIndex(this._zoomHost, this._browserZoomIndex, this._isInMemory); } } @@ -854,7 +855,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { const defaultIndex = this.zoomService.getEffectiveZoomIndex(undefined, false); await this.setBrowserZoomIndex(defaultIndex); if (this._zoomHost) { - this.zoomService.setHostZoomIndex(this._zoomHost, defaultIndex, this._isEphemeral); + this.zoomService.setHostZoomIndex(this._zoomHost, defaultIndex, this._isInMemory); } } diff --git a/src/vs/workbench/contrib/browserView/common/browserZoomService.ts b/src/vs/workbench/contrib/browserView/common/browserZoomService.ts index c0e3cc5023a62f..fa82ec35e8f82d 100644 --- a/src/vs/workbench/contrib/browserView/common/browserZoomService.ts +++ b/src/vs/workbench/contrib/browserView/common/browserZoomService.ts @@ -30,26 +30,26 @@ export interface IBrowserZoomChangeEvent { readonly host: string | undefined; /** - * Whether the change came from an ephemeral session. - * - `true` → only ephemeral views need to react. - * - `false` → all views (ephemeral and non-ephemeral) for the host may be affected. + * Whether the change came from an in-memory session. + * - `true` → only in-memory views need to react. + * - `false` → all views for the host may be affected. */ - readonly isEphemeralChange: boolean; + readonly isInMemoryChange: boolean; } /** * Manages two independent cascading zoom hierarchies for integrated browser views: * - * Normal views: `persistent per-host override` ?? `configured default` - * Ephemeral views: `ephemeral per-host override` ?? `configured default` + * Persistent views: `persistent per-host override` ?? `configured default` + * In-memory views: `in-memory per-host override` ?? `configured default` * - * Ephemeral views never see persistent overrides directly. Instead, when a persistent - * value changes, it is copied into the ephemeral map so that ephemeral views - * immediately reflect the new level. Conversely, ephemeral changes never affect - * normal views. + * In-memory views never see persistent overrides directly. Instead, when a persistent + * value changes, it is copied into the in-memory map so that in-memory views + * immediately reflect the new level. Conversely, in-memory changes never affect + * persistent views. * * Per-host values that equal the current default are always removed (both persistent - * and ephemeral), so the view tracks the default going forward. + * and in-memory), so the view tracks the default going forward. */ export interface IBrowserZoomService { readonly _serviceBrand: undefined; @@ -61,20 +61,20 @@ export interface IBrowserZoomService { * Returns the effective zoom index for the given host and session type. * Pass `host = undefined` to obtain only the configured default zoom index. */ - getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number; + getEffectiveZoomIndex(host: string | undefined, isInMemory: boolean): number; /** * Set the zoom for a host. * - * Non-ephemeral: persisted to storage. Also propagated into - * the ephemeral map so ephemeral views immediately reflect the change. + * Persistent: persisted to storage. Also propagated into + * the in-memory map so in-memory views immediately reflect the change. * - * Ephemeral: stored in memory only, dropped on restart. + * In-memory: stored in memory only, dropped on restart. * * In both cases, if the value equals the current default, the entry is removed so the * view tracks the default going forward. */ - setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void; + setHostZoomIndex(host: string, zoomIndex: number, isInMemory: boolean): void; /** * Notifies the service of the application's current UI zoom factor. @@ -106,7 +106,7 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic private _persistentZoomMap: Record; /** In-memory only; dropped on restart. */ - private readonly _ephemeralZoomMap = new Map(); + private readonly _inMemoryZoomMap = new Map(); private _windowZoomFactor: number = zoomLevelToZoomFactor(0); // default: zoom level 0 → factor 1.0 @@ -120,17 +120,17 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('workbench.browser.pageZoom')) { - this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false }); + this._onDidChangeZoom.fire({ host: undefined, isInMemoryChange: false }); } })); } - getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number { + getEffectiveZoomIndex(host: string | undefined, isInMemory: boolean): number { if (host !== undefined) { - if (isEphemeral) { - const ephemeralIndex = this._ephemeralZoomMap.get(host); - if (ephemeralIndex !== undefined) { - return this._clamp(ephemeralIndex); + if (isInMemory) { + const inMemoryIndex = this._inMemoryZoomMap.get(host); + if (inMemoryIndex !== undefined) { + return this._clamp(inMemoryIndex); } } else { const persistentIndex = this._persistentZoomMap[host]; @@ -143,24 +143,24 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic return this._getDefaultZoomIndex(); } - setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void { + setHostZoomIndex(host: string, zoomIndex: number, isInMemory: boolean): void { const clamped = this._clamp(zoomIndex); const defaultIndex = this._getDefaultZoomIndex(); const matchesDefault = clamped === defaultIndex; - if (isEphemeral) { + if (isInMemory) { if (matchesDefault) { - if (!this._ephemeralZoomMap.has(host)) { + if (!this._inMemoryZoomMap.has(host)) { return; } - this._ephemeralZoomMap.delete(host); + this._inMemoryZoomMap.delete(host); } else { - if (this._ephemeralZoomMap.get(host) === clamped) { + if (this._inMemoryZoomMap.get(host) === clamped) { return; } - this._ephemeralZoomMap.set(host, clamped); + this._inMemoryZoomMap.set(host, clamped); } - this._onDidChangeZoom.fire({ host, isEphemeralChange: true }); + this._onDidChangeZoom.fire({ host, isInMemoryChange: true }); } else { let persistentChanged = false; if (matchesDefault) { @@ -173,22 +173,22 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic persistentChanged = true; } - // Propagate to ephemeral map so ephemeral views immediately reflect the new level. - let ephemeralChanged = false; + // Propagate to the in-memory map so temporary views immediately reflect the new level. + let inMemoryChanged = false; if (matchesDefault) { - ephemeralChanged = this._ephemeralZoomMap.delete(host); - } else if (this._ephemeralZoomMap.get(host) !== clamped) { - this._ephemeralZoomMap.set(host, clamped); - ephemeralChanged = true; + inMemoryChanged = this._inMemoryZoomMap.delete(host); + } else if (this._inMemoryZoomMap.get(host) !== clamped) { + this._inMemoryZoomMap.set(host, clamped); + inMemoryChanged = true; } - if (!persistentChanged && !ephemeralChanged) { + if (!persistentChanged && !inMemoryChanged) { return; } if (persistentChanged) { this._writePersistentZoomMap(); } - this._onDidChangeZoom.fire({ host, isEphemeralChange: false }); + this._onDidChangeZoom.fire({ host, isInMemoryChange: false }); } } @@ -196,7 +196,7 @@ export class BrowserZoomService extends Disposable implements IBrowserZoomServic this._windowZoomFactor = windowZoomFactor; const label = this.configurationService.getValue('workbench.browser.pageZoom'); if (label === MATCH_WINDOW_ZOOM_LABEL) { - this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false }); + this._onDidChangeZoom.fire({ host: undefined, isInMemoryChange: false }); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts index 08f43c5b462e17..3258101bd6bc95 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -121,9 +121,38 @@ class ClearEphemeralBrowserStorageAction extends Action2 { } } +class ClearAgentBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearAgentStorage; + + constructor() { + super({ + id: ClearAgentBrowserStorageAction.ID, + title: localize2('browser.clearAgentStorageAction', 'Clear Storage (Agent)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Agent), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Data, + order: 20, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Agent), + isHiddenByDefault: true, + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.model?.clearStorage(); + } + } +} + registerAction2(ClearGlobalBrowserStorageAction); registerAction2(ClearWorkspaceBrowserStorageAction); registerAction2(ClearEphemeralBrowserStorageAction); +registerAction2(ClearAgentBrowserStorageAction); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ ...workbenchConfigurationNodeBase, @@ -134,13 +163,15 @@ Registry.as(ConfigurationExtensions.Configuration).regis 'default', BrowserViewStorageScope.Global, BrowserViewStorageScope.Workspace, - BrowserViewStorageScope.Ephemeral + BrowserViewStorageScope.Ephemeral, + BrowserViewStorageScope.Agent ], markdownEnumDescriptions: [ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.default' }, '`global` for local workspaces, `workspace` for remote workspaces.'), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces. Incompatible with remote sessions.'), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.agent' }, 'Browser views share one in-memory session that is cleaned up when the application is closed. This session is shared with agent-opened browser pages, so **all data including local storage and cookies will be accessible by agents.**') ], restricted: true, default: 'default', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts index 4271552380c6aa..ae08b0c940fed3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts @@ -346,7 +346,11 @@ class ShowBrowserHistoryAction extends Action2 { static readonly ID = BrowserViewCommandId.ShowHistory; constructor() { - const when = ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral).negate()); + const when = ContextKeyExpr.and( + BROWSER_EDITOR_ACTIVE, + ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral).negate(), + ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Agent).negate(), + ); super({ id: ShowBrowserHistoryAction.ID, title: localize2('browser.showHistory', 'History'), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index e6e5c69760c170..03eda20533303d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -27,6 +27,7 @@ import { BrowserChatToolReferenceName } from '../../../../../platform/browserVie import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult, getSessionId, remoteUrlRewriteNotice, rewriteRemoteLocalhostUrl } from './browserToolHelpers.js'; import { IRemoteExplorerService } from '../../../../services/remote/common/remoteExplorerService.js'; import { getAgentBrowserViewCreationDefaults } from '../../../../../platform/browserView/common/browserView.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; export const OpenPageToolId = 'open_browser_page'; const OPEN_PAGE_READY_TIMEOUT_MS = 5000; @@ -76,6 +77,7 @@ export class OpenBrowserTool implements IToolImpl { @IChatService private readonly chatService: IChatService, @IConfigurationService private readonly configService: IConfigurationService, @ILogService private readonly logService: ILogService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -287,7 +289,7 @@ export class OpenBrowserTool implements IToolImpl { private async _openNewPage(sessionId: string, url: string): Promise { const input = await this.browserViewService.createBrowserView({ - ...getAgentBrowserViewCreationDefaults(sessionId), + ...getAgentBrowserViewCreationDefaults(sessionId, this.environmentService.isSessionsWindow ? sessionId : undefined), initialUrl: url, openSource: 'cdpCreated' }, { preserveFocus: true }); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts index c43a987af1ea13..38f6e37c14e895 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts @@ -21,6 +21,7 @@ import { BrowserEditorInput } from '../../../common/browserEditorInput.js'; import { BrowserViewStorageScope, IBrowserViewEditorOpenOptions } from '../../../../../../platform/browserView/common/browserView.js'; import { IToolInvocation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; suite('OpenBrowserTool', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -40,6 +41,7 @@ suite('OpenBrowserTool', () => { upcastPartial({}), configService, upcastPartial({}), + upcastPartial({ isSessionsWindow: false }), ); const urls = [ @@ -88,6 +90,7 @@ suite('OpenBrowserTool', () => { upcastPartial({}), new TestConfigurationService(), upcastPartial({}), + upcastPartial({ isSessionsWindow: true }), ); await tool.invoke( @@ -105,7 +108,7 @@ suite('OpenBrowserTool', () => { owner: { type: 'agent', sessionId: 'chat:session' }, initialAudiences: [{ type: 'agent' }], session: { - scope: BrowserViewStorageScope.Ephemeral, + scope: BrowserViewStorageScope.Agent, affinity: 'chat:session' }, initialUrl: 'https://example.com', diff --git a/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts index ee296ecbd87dbb..ec71c031863624 100644 --- a/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts +++ b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts @@ -8,15 +8,18 @@ import { IChannel, ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js import { IPlaywrightService, IPlaywrightServiceInitializeOptions } from '../../../../platform/browserView/common/playwrightService.js'; import { registerSharedProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; class PlaywrightChannelClient { constructor( channel: IChannel, @ILogService logService: ILogService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { // Initialize the per-window shared-process service before forwarding calls. const options: IPlaywrightServiceInitializeOptions = { windowId: mainWindow.vscodeWindowId, + useSessionStorageAffinity: environmentService.isSessionsWindow, }; void channel.call('__initialize', options).catch((e) => { logService.error(`Failed to initialize Playwright service`, e); From fde1a8ddde8edd5b27a95d57e04178ccdc9c013b Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:09:04 -0700 Subject: [PATCH 19/41] sessions: stabilize Codicon background resizing (#333831) Agent Host changes for main --- .../chat/test/browser/chatView.test.ts | 49 +++++++++++++++++++ .../browser/chatBackgroundRenderer.ts | 23 +++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 4d6fec7b7b1453..246671e3ec4685 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -278,6 +278,55 @@ suite('Sessions - Chat View', () => { }); }); + test('keeps existing codicons stable when the background grid resizes', () => { + const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); + const part = dom.append(workbench, dom.$('.part.sessionspart')); + part.style.width = '960px'; + part.style.height = '800px'; + dom.getWindow(workbench).document.body.appendChild(workbench); + disposables.add(toDisposable(() => workbench.remove())); + const renderer = disposables.add(new SessionsChatBackgroundRenderer(part)); + renderer.setBackground({ kind: 'codicons' }); + const layer = part.querySelector(':scope > .sessions-chat-background > .sessions-chat-codicon-background'); + const firstIcon = layer?.querySelector('.codicon'); + const firstIconLeft = firstIcon?.style.left; + const firstIconTop = firstIcon?.style.top; + const initialIconCount = layer?.querySelectorAll('.codicon').length; + + part.style.width = '961px'; + renderer.setBackground({ kind: 'codicons' }); + const expandedFirstIcon = layer?.querySelector('.codicon'); + const expandedIconCount = layer?.querySelectorAll('.codicon').length; + + part.style.width = '960px'; + renderer.setBackground({ kind: 'codicons' }); + const shrunkFirstIcon = layer?.querySelector('.codicon'); + + assert.deepStrictEqual({ + initialIconCount, + expandedIconCount, + shrunkIconCount: layer?.querySelectorAll('.codicon').length, + reusedFirstIconWhenExpanded: expandedFirstIcon === firstIcon, + reusedFirstIconWhenShrunk: shrunkFirstIcon === firstIcon, + firstIconPositions: [ + { left: firstIconLeft, top: firstIconTop }, + { left: expandedFirstIcon?.style.left, top: expandedFirstIcon?.style.top }, + { left: shrunkFirstIcon?.style.left, top: shrunkFirstIcon?.style.top }, + ], + }, { + initialIconCount: 109, + expandedIconCount: 117, + shrunkIconCount: 109, + reusedFirstIconWhenExpanded: true, + reusedFirstIconWhenShrunk: true, + firstIconPositions: [ + { left: '125.6px', top: '46.4px' }, + { left: '125.6px', top: '46.4px' }, + { left: '125.6px', top: '46.4px' }, + ], + }); + }); + test('keeps the user request bubble opaque over the chat background', () => { const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); workbench.style.setProperty('--session-view-background', '#202020'); diff --git a/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts b/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts index 33594c23a8470a..e22a4fdb5d413e 100644 --- a/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts +++ b/src/vs/sessions/services/chatBackground/browser/chatBackgroundRenderer.ts @@ -61,6 +61,7 @@ export class SessionsChatBackgroundRenderer extends Disposable { private readonly backgroundLayer: HTMLElement; private readonly codiconLayer: HTMLElement; + private readonly codiconCells = new Map(); private background: ISessionsChatBackground | undefined; private codiconGridSize: string | undefined; @@ -112,6 +113,7 @@ export class SessionsChatBackgroundRenderer extends Disposable { this.renderCodicons(this.element.clientWidth, this.element.clientHeight); } else { this.codiconGridSize = undefined; + this.codiconCells.clear(); clearNode(this.codiconLayer); } } @@ -130,24 +132,39 @@ export class SessionsChatBackgroundRenderer extends Disposable { this.codiconGridSize = gridSize; const fragment = this.element.ownerDocument.createDocumentFragment(); + const visibleCells = new Set(); for (let row = 0; row < rows; row++) { for (let column = 0; column < columns; column++) { if (hashCodiconCell(row, column, 0) % 9 === 0) { continue; } + + const cell = `${row}:${column}`; + visibleCells.add(cell); + if (this.codiconCells.has(cell)) { + continue; + } + const icon = renderIcon(codiconChoices[hashCodiconCell(row, column, 1) % codiconChoices.length]); icon.ariaHidden = 'true'; const horizontalOffset = ((hashCodiconCell(row, column, 2) % 71) - 35) / 100; const verticalOffset = ((hashCodiconCell(row, column, 3) % 65) - 32) / 100; const rotation = (hashCodiconCell(row, column, 4) % 71) - 35; - icon.style.left = `${((column + 0.5 + horizontalOffset) / columns) * 100}%`; - icon.style.top = `${((row + 0.5 + verticalOffset) / rows) * 100}%`; + icon.style.left = `${(column + 0.5 + horizontalOffset) * codiconCellSize}px`; + icon.style.top = `${(row + 0.5 + verticalOffset) * codiconCellSize}px`; icon.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`; icon.style.opacity = `${0.65 + (hashCodiconCell(row, column, 5) % 36) / 100}`; + this.codiconCells.set(cell, icon); fragment.append(icon); } } - clearNode(this.codiconLayer); + + for (const [cell, icon] of this.codiconCells) { + if (!visibleCells.has(cell)) { + icon.remove(); + this.codiconCells.delete(cell); + } + } this.codiconLayer.append(fragment); } } From b176e3d456cb38e7e35baa7e8bd72c8f647aa307 Mon Sep 17 00:00:00 2001 From: roblourens Date: Tue, 1 Sep 2026 12:20:58 -0700 Subject: [PATCH 20/41] chat: use Copilot icon for Agent Host sessions (#333813) (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/agentSessions/agentSessions.ts | 2 +- .../test/browser/agentSessions/agentSessionViewModel.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 9e50ecd1e2a4c9..1ede7615d60582 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -91,7 +91,7 @@ export function getAgentSessionProviderIcon(provider: AgentSessionTarget): Theme case AgentSessionProviders.Growth: return Codicon.lightbulb; case AgentSessionProviders.AgentHostCopilot: - return Codicon.vm; + return Codicon.copilot; default: return Codicon.extensions; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index d9751e4d897166..59ff699fd4eb98 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2621,7 +2621,7 @@ suite('AgentSessions', () => { test('should return correct icon for AgentHostCopilot provider', () => { const icon = getAgentSessionProviderIcon(AgentSessionProviders.AgentHostCopilot); - assert.strictEqual(icon.id, Codicon.vm.id); + assert.strictEqual(icon.id, Codicon.copilot.id); }); test('should return simplified AgentHostCopilot name', () => { From 91b0cac71e0be5aea56e4914f72a957f6ca2fb4e Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 1 Sep 2026 21:26:40 +0200 Subject: [PATCH 21/41] Bound fetcher telemetry error cardinality (#333796) * Bound fetcher telemetry error cardinality Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../networking/node/fetcherFallback.ts | 8 ++- .../test/node/fetcherFallback.spec.ts | 66 ++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/platform/networking/node/fetcherFallback.ts b/extensions/copilot/src/platform/networking/node/fetcherFallback.ts index 745dac6427909a..18001d41ebc523 100644 --- a/extensions/copilot/src/platform/networking/node/fetcherFallback.ts +++ b/extensions/copilot/src/platform/networking/node/fetcherFallback.ts @@ -20,6 +20,7 @@ const fetcherConfigKeys: Partial>> = { const terminalResponseStatusCodes = new Set([429, 502, 503]); const allFetchersFailedTelemetryIntervalMs = 15 * 60 * 1000; const maxAggregatedErrorLength = 1024; +const maxAggregatedErrorCount = 100; const maxAggregatedErrorsSerializedLength = 8192; const aggregatedErrorsOverflowKey = ''; const allFetchersFailedErrors = new Map(); @@ -85,7 +86,12 @@ function recordErrors(target: Map, errors: readonly string[]): v target.set(truncatedError, Math.min(count + 1, Number.MAX_SAFE_INTEGER)); continue; } - target.set(truncatedError, 1); + if (target.size < maxAggregatedErrorCount) { + target.set(truncatedError, 1); + continue; + } + const overflowCount = target.get(aggregatedErrorsOverflowKey) ?? 0; + target.set(aggregatedErrorsOverflowKey, Math.min(overflowCount + 1, Number.MAX_SAFE_INTEGER)); } } diff --git a/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts b/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts index 50d94fba8ffeba..1e6a420975f6a7 100644 --- a/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts +++ b/extensions/copilot/src/platform/networking/test/node/fetcherFallback.spec.ts @@ -296,6 +296,66 @@ suite('FetcherFallback Test Suite', function () { } }); + test('caps terminal response error cardinality while preserving counts', async function () { + vi.useFakeTimers(); + cleanupTime += 24 * 60 * 60 * 1000; + vi.setSystemTime(cleanupTime); + const primingTelemetryService = new SpyingTelemetryService(); + const primingFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(503, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(primingFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + vi.advanceTimersByTime(16 * 60 * 1000); + const createSuccessfulFetchers = () => createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(200, someJSON) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, primingTelemetryService, experimentationService); + const spyingTelemetryService = new SpyingTelemetryService(); + try { + for (let i = 0; i < 200; i++) { + const testFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(503, someJSON, `unique status ${i}`) }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(testFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + } + const repeatedOverflowFetchers = createTestFetchers([ + { name: 'fetcher1', response: createFakeResponse(503, someJSON, 'unique status 199') }, + { name: 'fetcher2', response: createFakeResponse(200, someJSON) }, + ]); + await fetchWithFallbacks(repeatedOverflowFetchers.fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + + vi.advanceTimersByTime(15 * 60 * 1000 + 1); + await fetchWithFallbacks(createSuccessfulFetchers().fetchers, 'https://example.com', { callSite: 'test', expectJSON: true, retryFallbacks: true }, knownBadFetchers, configurationService, logService, spyingTelemetryService, experimentationService); + const telemetryEvents = spyingTelemetryService.getEvents().telemetryServiceEvents; + const properties = telemetryEvents[0].properties; + if (!properties || !('errors' in properties) || typeof properties.errors !== 'string') { + assert.fail('Expected an errors telemetry property'); + } + const errors: Record = JSON.parse(properties.errors); + + assert.deepStrictEqual({ + eventCount: telemetryEvents.length, + eventName: telemetryEvents[0].eventName, + errorKeyCount: Object.keys(errors).length, + overflowCount: errors[''], + repeatedOverflowErrorCount: errors['fetcher1: 503 unique status 199'], + allFailuresAccountedFor: Object.values(errors).reduce((total, count) => total + count, 0), + }, { + eventCount: 1, + eventName: 'fetcherTerminalResponse', + errorKeyCount: 101, + overflowCount: 101, + repeatedOverflowErrorCount: undefined, + allFailuresAccountedFor: 201, + }); + } finally { + vi.useRealTimers(); + } + }); + test('no fetcher succeeds', async function () { const fetcherSpec = [ { name: 'fetcher1', response: createFakeResponse(407, someHTML) }, @@ -407,7 +467,7 @@ function createTestFetchers(fetcherSpecs: Array<{ name: string; response: Respon } return next; }, - fetchWithPagination: async (baseUrl: string, options: PaginationOptions): Promise => { + fetchWithPagination: async (_baseUrl: string, _options: PaginationOptions): Promise => { throw new Error('Method not implemented.'); }, disconnectAll: async () => { }, @@ -422,10 +482,10 @@ function createTestFetchers(fetcherSpecs: Array<{ name: string; response: Respon return { fetchers, calls }; } -function createFakeResponse(statusCode: number, content: string) { +function createFakeResponse(statusCode: number, content: string, statusText = 'status text') { return Response.fromText( statusCode, - 'status text', + statusText, new FakeHeaders(), content, 'test-stub' From 46f6e0967f1a8263329d952151be2107b27980a3 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:29:06 -0700 Subject: [PATCH 22/41] Fix sign-in dead-end in installed web apps (PWA) (#333084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reserve the sign-in window during the user gesture Browsers only allow `window.open` while a click's user activation is still live. Chat sign-in shows a dialog, activates an extension and fetches an authorization URL first, so by the time it opens anything the gesture has expired and Safari refuses the window. In a browser tab this surfaces as a "browser blocked opening a new tab" prompt whose Retry works, because Retry runs inside a fresh click. In an installed web app (PWA) on iOS there is no tab to fall back to and the prompt is covered by the "Signing in..." modal, so sign-in dead-ends with no way forward. Claim the window synchronously on the click instead, and let `windowOpenWithSuccess` navigate that reserved window once the URL is known. Gated to Safari, so other browsers are unaffected. The reservation is released when setup ends without using it, so a cancelled or failed sign-in does not leave a blank window over the app. Verified on an iPhone PWA that `window.open()` succeeds inside a real tap and is refused outside one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cover installed web apps in any browser, not just Safari The reservation was gated on Safari, but the failure is a property of installed web apps rather than of one browser: an app installed through Edge or Chrome has no tab to fall back to either, and sign-in dead-ends there the same way. Gate on `isStandalone() || isSafari` instead, and route standalone through the open-then-navigate path so a reserved window is actually consumed. Browser tabs outside Safari are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Do not leave a blank window behind when sign-in does not complete The reserved window is released when setup ends without using it, but `close()` is not always honoured — an in-app browser view on iOS can ignore it — which would leave a blank window covering the app. That is worse than the popup block this change exists to fix. Show a short explanation in the window when it refuses to close, and render the placeholder via `textContent` on a document that follows the system colour scheme, so it neither flashes white nor can inject markup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback - Reserve only for strategies that actually enter provider authentication. `DefaultSetup` can install and sign up without any browser round trip for an already signed-in user, so reserving for it left a window covering the app for the whole of setup. - Open from the window the click happened in. User activation belongs to that window, so a dialog shown in an auxiliary window could still be blocked when opening from `mainWindow`. - Give the placeholder document a title, so screen readers and window switchers do not announce it as `about:blank`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Trim comments to the non-obvious parts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Narrow the popup workaround to installed mobile apps `isStandalone()` also matches desktop installed apps, including a self-hosted VS Code installed from the server's manifest. Those get real windows and can open popups after the gesture, so they were being pulled onto the open-then-navigate path — and its manual `opener` nulling — for no benefit. Add `isMobileStandalone()` and gate on that instead, keeping the workaround where a blocked popup is actually unrecoverable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/browser.ts | 13 +++ src/vs/base/browser/dom.ts | 84 ++++++++++++++++++- src/vs/workbench/browser/window.ts | 6 +- .../chat/browser/chatSetup/chatSetupRunner.ts | 47 ++++++++++- 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/vs/base/browser/browser.ts b/src/vs/base/browser/browser.ts index b6e9ec09fff6f0..7b6cd63133cbdc 100644 --- a/src/vs/base/browser/browser.ts +++ b/src/vs/base/browser/browser.ts @@ -5,6 +5,7 @@ import { CodeWindow, mainWindow } from './window.js'; import { Emitter } from '../common/event.js'; +import { isIOS } from '../common/platform.js'; class WindowManager { @@ -127,6 +128,18 @@ export function isStandalone(): boolean { return standalone; } +/** + * Whether we are an installed web app (PWA) on a phone or tablet. + * + * These have no browser tab to fall back on, so anything a tab could recover from + * — a blocked popup, a navigation that lands somewhere unexpected — is a dead end + * here. Desktop installed apps still get real windows and are deliberately not + * included. + */ +export function isMobileStandalone(): boolean { + return isStandalone() && (isIOS || isAndroid); +} + // Visible means that the feature is enabled, not necessarily being rendered // e.g. visible is true even in fullscreen mode where the controls are hidden // See docs at https://developer.mozilla.org/en-US/docs/Web/API/WindowControlsOverlay/visible diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 4a9cffc80f757c..ac3f08fdc23bf1 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1637,6 +1637,85 @@ export function windowOpenPopup(url: string): void { ); } +let reservedExternalWindow: Window | undefined; + +function isUsable(candidate: Window | undefined): candidate is Window { + return !!candidate && !candidate.closed; +} + +/** + * Opens a blank window now, for a later {@link windowOpenWithSuccess} to navigate. + * + * Browsers only allow `window.open` while a click's user activation is still live. + * Sign-in shows a dialog, activates an extension and fetches an authorization URL + * first, so by then the gesture has expired and the window is refused — fatal in an + * installed web app (PWA), where there is no tab to fall back to. + * + * Callers must consume or {@link releaseReservedWindowForExternalOpen} the + * reservation, or a blank window is left covering the app. + * + * @param targetWindow the window that was clicked; activation belongs to it. + * @param placeholder already-translated text, since `vs/base` cannot localize. + */ +export function reserveWindowForExternalOpen(targetWindow: Window = mainWindow, placeholder?: string): void { + if (isUsable(reservedExternalWindow)) { + return; + } + + reservedExternalWindow = targetWindow.open() ?? undefined; + if (!isUsable(reservedExternalWindow)) { + reservedExternalWindow = undefined; + return; + } + + if (placeholder) { + showMessageInWindow(reservedExternalWindow, placeholder); + } +} + +/** Uses `textContent` so the message cannot inject markup. */ +function showMessageInWindow(target: Window, message: string): void { + try { + const doc = target.document; + doc.title = message; // otherwise announced as `about:blank` + doc.documentElement.style.cssText = 'color-scheme:light dark'; + doc.body.style.cssText = 'margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:Canvas;color:CanvasText;font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif'; + doc.body.textContent = message; + } catch { + // the window may already have navigated + } +} + +function takeReservedWindowForExternalOpen(): Window | undefined { + const reserved = reservedExternalWindow; + reservedExternalWindow = undefined; + return isUsable(reserved) ? reserved : undefined; +} + +/** + * Closes an unused reservation. + * + * @param fallbackMessage shown if the window refuses to close — an in-app browser + * view on iOS may ignore `close()`, and a blank window covering the app is worse + * than the problem this solves. + */ +export function releaseReservedWindowForExternalOpen(fallbackMessage?: string): void { + const reserved = takeReservedWindowForExternalOpen(); + if (!reserved) { + return; + } + + try { + reserved.close(); + } catch { + // the window may already have navigated + } + + if (!reserved.closed && fallbackMessage) { + showMessageInWindow(reserved, fallbackMessage); + } +} + /** * Attempts to open a window and returns whether it succeeded. This technique is * not appropriate in certain contexts, like for example when the JS context is @@ -1650,10 +1729,11 @@ export function windowOpenPopup(url: string): void { * @param url the url to open * @param noOpener whether or not to set the {@link window.opener} to null. You should leave the default * (true) unless you trust the url that is being opened. + * @param targetWindow the window to open from when no window was reserved. * @returns boolean indicating if the {@link window.open} call succeeded */ -export function windowOpenWithSuccess(url: string, noOpener = true): boolean { - const newTab = mainWindow.open(); +export function windowOpenWithSuccess(url: string, noOpener = true, targetWindow: Window = mainWindow): boolean { + const newTab = takeReservedWindowForExternalOpen() ?? targetWindow.open(); if (newTab) { if (noOpener) { // see `windowOpenNoOpener` for details on why this is important diff --git a/src/vs/workbench/browser/window.ts b/src/vs/workbench/browser/window.ts index 09cddff2956470..7f353636307716 100644 --- a/src/vs/workbench/browser/window.ts +++ b/src/vs/workbench/browser/window.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isSafari, setFullscreen } from '../../base/browser/browser.js'; +import { isSafari, isMobileStandalone, setFullscreen } from '../../base/browser/browser.js'; import { addDisposableListener, EventHelper, EventType, getWindow, getWindowById, getWindows, getWindowsCount, hasAppFocus, windowOpenNoOpener, windowOpenPopup, windowOpenWithSuccess } from '../../base/browser/dom.js'; import { DomEmitter } from '../../base/browser/event.js'; import { HidDeviceData, requestHidDevice, requestSerialPort, requestUsbDevice, SerialPortData, UsbDeviceData } from '../../base/browser/deviceAccess.js'; @@ -355,7 +355,9 @@ export class BrowserWindow extends BaseWindow { // HTTP(s): open in new window and deal with potential popup blockers if (matchesScheme(href, Schemas.http) || matchesScheme(href, Schemas.https)) { - if (isSafari) { + // Both block popups opened outside a user gesture, so use the + // open-then-navigate path, which can pick up a reserved window. + if (isSafari || isMobileStandalone()) { const opened = windowOpenWithSuccess(href, !isAllowedOpener); if (!opened) { await this.dialogService.prompt({ diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 2640406797dde3..c1ea8aa7a9c5dc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatSetup.css'; -import { $ } from '../../../../../base/browser/dom.js'; +import { $, getWindow, releaseReservedWindowForExternalOpen, reserveWindowForExternalOpen } from '../../../../../base/browser/dom.js'; +import { isSafari, isMobileStandalone } from '../../../../../base/browser/browser.js'; +import { IButton } from '../../../../../base/browser/ui/button/button.js'; import { Dialog, DialogContentsAlignment } from '../../../../../base/browser/ui/dialog/dialog.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -93,6 +95,24 @@ export interface IChatSetupDialogOptions { readonly renderFooter?: (container: HTMLElement) => IDisposable | undefined; } +/** + * Whether this strategy sends the user to a provider's sign-in page. `DefaultSetup` + * is excluded: for an already signed-in user it installs and signs up with no + * browser round trip. + */ +function entersProviderAuthentication(strategy: ChatSetupStrategy): boolean { + switch (strategy) { + case ChatSetupStrategy.SetupWithEnterpriseProvider: + case ChatSetupStrategy.SetupWithoutEnterpriseProvider: + case ChatSetupStrategy.SetupWithGoogleProvider: + case ChatSetupStrategy.SetupWithAppleProvider: + case ChatSetupStrategy.SetupWithMicrosoftProvider: + return true; + default: + return false; + } +} + export class ChatSetupDialog extends Disposable { private readonly dialog: Dialog; @@ -135,7 +155,26 @@ export class ChatSetupDialog extends Disposable { }, buttonOptions: options.buttons.map(button => { const classes = button.classes; - return classes ? { styleButton: control => control.element.classList.add(...classes) } : undefined; + // Claim the sign-in window while the click's activation is still live; + // see `reserveWindowForExternalOpen`. Only installed mobile apps (fatal, + // no tab to fall back to) and Safari (recoverable via "Retry") need this. + const opensBrowser = (isMobileStandalone() || isSafari) && entersProviderAuthentication(button.strategy); + if (!classes && !opensBrowser) { + return undefined; + } + return { + styleButton: (control: IButton) => { + if (classes?.length) { + control.element.classList.add(...classes); + } + if (opensBrowser) { + this._register(control.onDidClick(() => reserveWindowForExternalOpen( + getWindow(control.element), + localize('signingInPlaceholder', "Signing in…") + ))); + } + } + }; }) }, keybindingService, layoutService, hostService) )); @@ -372,6 +411,10 @@ export class ChatSetup { } } finally { setupCancellation.dispose(); + // no browser window was opened, so the reservation is still blank + releaseReservedWindowForExternalOpen( + localize('signInDidNotComplete', "Sign-in did not complete. You can close this window.") + ); } if (success) { From bf1d0a25dd3a60c3711e7566409d2bebfcac29e9 Mon Sep 17 00:00:00 2001 From: Jessie Houghton <46505805+houghj16@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:29:35 -0700 Subject: [PATCH 23/41] chat: Improve uninstalled MCP server details (#333664) * chat: improve uninstalled MCP server details Explain when MCP server details become available, avoid moving focus on pointer activation, and align the empty state styling with the detail header. Retry gallery loading when the configured manifest becomes available so the Featured section does not remain empty after startup. Fixes #333556. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: address MCP detail review feedback Initialize and refresh gallery state reliably, restore accessible focus navigation, align detail messaging with shared geometry, and register gallery manifest mocks for affected fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: type MCP detail back button Create the MCP detail Back control as an HTMLButtonElement so its stored focus target satisfies the client type check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: update MCP detail screenshot hashes Add the Ubuntu-generated blocks-ci hashes for the new uninstalled MCP detail fixture in dark and light themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationManagementEditor.ts | 6 +++-- .../embeddedMcpServerDetail.ts | 8 ++++-- .../browser/aiCustomization/mcpListWidget.ts | 17 ++++++++++++ .../media/aiCustomizationManagement.css | 27 +++++++++++++++---- ...aiCustomizationManagementEditor.fixture.ts | 19 +++++++++++++ .../blocks-ci-screenshots.md | 6 +++++ 6 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 033974e44d11d6..e5874fbaf1428b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -358,6 +358,7 @@ export class AICustomizationManagementEditor extends EditorPane { // Embedded MCP server detail view private mcpDetailContainer: HTMLElement | undefined; private embeddedMcpDetail: EmbeddedMcpServerDetail | undefined; + private mcpDetailBackButton: HTMLButtonElement | undefined; private readonly mcpDetailDisposables = this._register(new DisposableStore()); // Embedded plugin detail view @@ -3254,7 +3255,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.embeddedMcpDetail = this.editorDisposables.add(this.instantiationService.createInstance(EmbeddedMcpServerDetail, detailBody)); // Back button rendered into the detail's leading slot - const backButton = DOM.append(this.embeddedMcpDetail.leadingSlot, $('button.editor-back-button')); + const backButton = DOM.append(this.embeddedMcpDetail.leadingSlot, $('button.editor-back-button')); + this.mcpDetailBackButton = backButton; backButton.setAttribute('type', 'button'); backButton.setAttribute('aria-label', localize('backToMcpList', "Back to MCP servers")); this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), backButton, localize('backToMcpListTooltip', "Back to MCP servers"))); @@ -3282,7 +3284,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.dimension) { this.layout(this.dimension); } - this.embeddedMcpDetail.focus(); + this.mcpDetailBackButton?.focus(); } private goBackFromMcpDetail(): void { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts index 323dd2b71dd7d5..1b89cc9dbe00c0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts @@ -18,7 +18,7 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; -import { IMcpWorkbenchService, IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, McpServerInstallState } from '../../../mcp/common/mcpTypes.js'; const $ = DOM.$; @@ -26,6 +26,7 @@ export interface IMcpServerDetailInput { readonly id: string; readonly name: string; readonly label: string; + readonly installState: McpServerInstallState; readonly config?: IMcpServerConfiguration; readonly source?: { readonly uri: URI; @@ -38,6 +39,7 @@ export function createWorkbenchMcpServerDetailInput(server: IWorkbenchMcpServer) id: server.id, name: server.name, label: server.label, + installState: server.installState, config: server.config, source: server.local?.mcpResource ? { uri: server.local.mcpResource } : undefined, }; @@ -151,7 +153,9 @@ export class EmbeddedMcpServerDetail extends Disposable { this.nameEl.textContent = server.label || server.name; this.pathEl.textContent = server.source ? basename(server.source.uri) : 'mcp.json'; - if (server.config) { + if (server.installState !== McpServerInstallState.Installed) { + this.setDefinition(undefined, localize('mcpDefinitionAvailableAfterInstall', "Details are available after install when the MCP server can be inspected locally.")); + } else if (server.config) { this.setDefinition(`${JSON.stringify({ servers: { [server.name]: server.config } }, null, '\t')}\n`); } else if (server.source) { this.setDefinition(undefined, localize('mcpDefinitionLoading', "Loading MCP server definition...")); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 2910ce60e8403f..889110336982f5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -17,6 +17,7 @@ import { defaultButtonStyles, defaultInputBoxStyles, getButtonStyles } from '../ import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { IMcpGalleryManifestService } from '../../../../../platform/mcp/common/mcpGalleryManifest.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerDefinition, McpServerInstallState, IMcpService, IMcpServer, McpServerTransportType } from '../../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../mcp/common/discovery/pluginMcpDiscovery.js'; @@ -1011,6 +1012,7 @@ function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry): IMcpSer id: getMcpRowKey(entry), name: getMcpEntryLabel(entry), label: getMcpEntryLabel(entry), + installState: McpServerInstallState.Installed, config: localDefinition ? getMcpServerConfiguration(localDefinition) : undefined, source: localSource ?? activeSessionSource, }; @@ -1112,6 +1114,7 @@ export class McpListWidget extends Disposable { @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @INotificationService private readonly notificationService: INotificationService, + @IMcpGalleryManifestService mcpGalleryManifestService: IMcpGalleryManifestService, ) { super(); this.element = $('.mcp-list-widget.plugin-list-widget'); @@ -1123,6 +1126,20 @@ export class McpListWidget extends Disposable { )); this._register(resizeObserver.observe(this.element)); this.updateAccessState(); + this._register(mcpGalleryManifestService.onDidChangeMcpGalleryManifest(() => { + this.galleryCts?.dispose(true); + this.galleryCts = undefined; + this.gallerySnapshotServers = []; + this.galleryServers = []; + this.gallerySnapshotFailed = false; + this.gallerySnapshotLoading = false; + if (this.searchQuery.trim()) { + void this.queryMcpSearch(); + } else { + void this.refresh(); + } + })); + void mcpGalleryManifestService.getMcpGalleryManifest(); void this.refresh(); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(mcpAccessConfig)) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 09bb1538ff5378..18ab773f1e98d5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1694,10 +1694,27 @@ per-word capitalization does not survive translation. */ } .ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-empty { - margin: var(--vscode-spacing-size80) var(--vscode-spacing-size160) var(--vscode-spacing-size160); - padding: var(--vscode-spacing-size160); - border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); - border-radius: var(--vscode-cornerRadius-medium); + margin: var(--vscode-spacing-size80) calc(var(--vscode-spacing-size160) - var(--vscode-spacing-size40)) var(--vscode-spacing-size160); + padding-inline: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-small); +} + +.ai-customization-management-editor .embedded-mcp-detail .embedded-detail-leading-slot:empty { + display: none; +} + +.ai-customization-management-editor .mcp-detail-container .mcp-detail-header { + gap: var(--vscode-spacing-size80); + padding-inline: var(--vscode-spacing-size160); +} + +.ai-customization-management-editor .mcp-detail-container .editor-back-button { + width: var(--vscode-spacing-size280); + height: var(--vscode-spacing-size280); +} + +.ai-customization-management-editor .mcp-detail-container .mcp-detail-definition-empty { + margin-inline-start: calc(var(--vscode-spacing-size160) + var(--vscode-spacing-size280) + var(--vscode-spacing-size80) - var(--vscode-spacing-size40)); } /* The tools detail lists many short tool descriptions, so let it use the full editor width. */ @@ -2110,7 +2127,7 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-empty { color: var(--vscode-descriptionForeground); font-size: var(--vscode-fontSize-body1); - padding: 8px 0; + padding-block: var(--vscode-spacing-size80); } /* Included tools list (tool extension detail) */ 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 d282f82685562e..d4ef84286f36e5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -73,6 +73,7 @@ import { AICustomizationManagementEditorInput } from '../../../../contrib/chat/b import { IConfigurationService, IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { IMcpGalleryManifestService, McpGalleryManifestStatus } from '../../../../../platform/mcp/common/mcpGalleryManifest.js'; import { McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; import { IAutomationDialogService } from '../../../../contrib/chat/common/automations/automationDialogService.js'; @@ -108,6 +109,15 @@ import '../../../../contrib/chat/browser/aiCustomization/media/aiCustomizationMa const userHome = URI.file('/home/dev'); const BUILTIN_STORAGE = 'builtin'; +function createMockMcpGalleryManifestService(): IMcpGalleryManifestService { + return new class extends mock() { + override readonly mcpGalleryManifestStatus = McpGalleryManifestStatus.Unavailable; + override readonly onDidChangeMcpGalleryManifestStatus = Event.None; + override readonly onDidChangeMcpGalleryManifest = Event.None; + override async getMcpGalleryManifest() { return null; } + }(); +} + interface IFixtureFile { readonly uri: URI; readonly storage: PromptsStorage; @@ -818,6 +828,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor [ChatConfiguration.ChatCustomizationsUserDataMigrationEnabled]: true, })); reg.define(IListService, ListService); + reg.defineInstance(IMcpGalleryManifestService, createMockMcpGalleryManifestService()); reg.defineInstance(ITextModelService, new class extends mock() { declare readonly _serviceBrand: undefined; override async createModelReference(resource: URI): Promise> { @@ -1230,6 +1241,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise additionalServices: (reg) => { registerWorkbenchServices(reg); reg.define(IListService, ListService); + reg.defineInstance(IMcpGalleryManifestService, createMockMcpGalleryManifestService()); reg.defineInstance(IMcpWorkbenchService, new class extends mock() { override readonly onChange = Event.None; override readonly onReset = Event.None; @@ -1503,6 +1515,7 @@ function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): voi additionalServices: (reg) => { registerWorkbenchServices(reg); reg.define(IListService, ListService); + reg.defineInstance(IMcpGalleryManifestService, createMockMcpGalleryManifestService()); reg.defineInstance(IConfigurationService, createDisabledConfigService(mcpAccessConfig, McpAccessValue.None, byPolicy)); reg.defineInstance(IMcpWorkbenchService, new class extends mock() { override readonly onChange = Event.None; @@ -2287,6 +2300,12 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { })), }), + // Standalone embedded MCP detail widget before marketplace installation. + EmbeddedMcpDetailUninstalled: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEmbeddedMcpDetail(ctx, galleryServers[0]), + }), + // Standalone embedded MCP detail widget — empty / no input state. EmbeddedMcpDetailEmpty: defineComponentFixture({ labels: { kind: 'screenshot' }, diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 31be6b91a1ddd1..402235158e8e90 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -18,6 +18,12 @@ #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/e652f2a9309c421bf0a7c805160521ad2837e320fdec9e00393ff981a2eac014) +#### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5c4c329b3df33c748b8cd46ba7585490b58b2d42fa71c25a44a7f327b344b1b) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b150bed8ecf034a2ac61bc46081007101d109ed98bc176824e045965dfff94da) + #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/34ca750e1747a1e89fbeaab3b755d1372ee503e009f4c2a32a4d7205742b3dce) From 9fefe249cb7b3b373294607eb814177ff6ca0cf9 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 1 Sep 2026 15:34:15 -0400 Subject: [PATCH 24/41] Address Auto routing profile review feedback (#333803) * agentHost: address Auto routing profile review feedback Register AgentHostCopilotCliSettingsContribution in the Agents Window, whose manual registrations omitted it, so the `chat.agentHost.copilot.*` gates reach the host's root config there. Resolve a provisional session's routing profile once at materialization and use it for both the launch plan and the persisted model, so a gate turned off before the first send cannot leave metadata claiming a profile the runtime never got. Normalize a persisted picker tier before validating it, so a value stored under the retired names upgrades instead of silently falling back. Also cover AutoModeTiers in the exhaustive settings-forwarding test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d0e056c-277d-40c3-ba90-eb5fe6653948 * agentHost: freeze the Auto routing profile on the launch plan Resolving the profile at materialization was not enough: the launcher read the gate again inside createSession, so a flip during the async launch could send one profile and persist another. Carry the resolved profile on the launch plan instead, and have the launcher and the session's launchAutoTier use that exact value rather than re-resolving. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d0e056c-277d-40c3-ba90-eb5fe6653948 * agentHost: simplify Auto routing profile comments Reword the comments added for the launch-plan freeze into plainer sentences. Comment-only change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d0e056c-277d-40c3-ba90-eb5fe6653948 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d0e056c-277d-40c3-ba90-eb5fe6653948 --- .../platform/endpoint/common/autoModeTiers.ts | 7 ++-- .../platform/endpoint/node/automodeService.ts | 6 +-- .../node/test/automodeService.spec.ts | 26 ++++++++++++ .../agentHost/node/copilot/copilotAgent.ts | 40 +++++++++++++------ .../node/copilot/copilotAgentSession.ts | 7 ++-- .../node/copilot/copilotSessionLauncher.ts | 13 ++++-- .../agentHost/test/node/copilotAgent.test.ts | 32 +++++++++++++++ .../test/node/copilotSessionLauncher.test.ts | 28 ++++++------- .../browser/localAgentHost.contribution.ts | 4 ++ ...HostCopilotCliSettingsContribution.test.ts | 8 +++- 10 files changed, 127 insertions(+), 44 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts index 1bb0277c9aafca..ba23d46ee35eb1 100644 --- a/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts +++ b/extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts @@ -40,10 +40,11 @@ const retiredAutoModeTiers: Readonly> = { /** * Maps a retired tier name to its current one, leaving anything else untouched. - * Only raw inputs that bypass the picker schema need this, notably the override setting. + * Needed for raw inputs that predate the rename, such as the override setting or a persisted picker + * value restored before its model's schema has loaded. */ -export function normalizeAutoModeTier(value: string): string { - return retiredAutoModeTiers[value] ?? value; +export function normalizeAutoModeTier(value: unknown): unknown { + return typeof value === 'string' ? retiredAutoModeTiers[value] ?? value : value; } /** diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 9df47934997019..77ec550083470d 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -404,11 +404,9 @@ export class AutomodeService extends Disposable implements IAutomodeService { private _resolveTier(chatRequest: IAutoModeRoutingRequest | undefined): AutoModeTier | undefined { const override = this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTierOverride); if (override) { - // Normalized because the override is a raw string setting, so unlike a picker value it never - // passes through the schema filter that drops retired names. const normalized = normalizeAutoModeTier(override); // The override is internal, so unlike the picker it may select `fast`. - if ((autoModeTiers as readonly string[]).includes(normalized)) { + if (autoModeTiers.some(tier => tier === normalized)) { return normalized as AutoModeTier; } this._logService.warn(`[AutomodeService] Ignoring auto tier override '${override}' — not one of [${autoModeTiers.join(', ')}].`); @@ -416,7 +414,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { if (!this.areAutoModeTiersSupported()) { return undefined; } - const configured = chatRequest?.modelConfiguration?.[AUTO_MODE_TIER_PROPERTY]; + const configured = normalizeAutoModeTier(chatRequest?.modelConfiguration?.[AUTO_MODE_TIER_PROPERTY]); if (isSelectableAutoModeTier(configured) && configured !== defaultAutoModeTier) { return configured; } diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index 23fce85c0d1588..e43a3a6003cb9b 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -870,6 +870,32 @@ describe('AutomodeService', () => { expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'efficiency' }]); }); + + // A picker value stored before the rename can be restored unfiltered while its model's + // schema is still loading, so it must upgrade rather than silently fall back. + it('maps a retired tier name in a persisted picker value to its current one', async () => { + enableTiers(); + const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); + mockAuto(autoResponse('gpt-4o')); + + automodeService = createService(); + await automodeService.resolveAutoModeEndpoint({ + location: ChatLocation.Panel, + prompt: 'panel turn', + sessionId: 'session-persisted-retired', + modelConfiguration: { tier: 'max' }, + } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]); + // `balanced` upgrades to the current default, which reads as "never picked", so the inline + // pin applies instead of being treated as an explicit selection. + await automodeService.resolveAutoModeEndpoint({ + location: ChatLocation.Editor, + prompt: 'inline turn', + sessionId: 'session-persisted-retired-default', + modelConfiguration: { tier: 'balanced' }, + } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]); + + expect(autoRequestBodies().map(b => b.tier)).toEqual(['intelligence', 'fast']); + }); }); describe('session cache', () => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 842ef94b3f7a64..0c41480a02b593 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -44,7 +44,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; -import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js'; +import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel, type AutoModeTier } from '../../common/autoModeTiers.js'; import { isAutoModel } from './modelIdentifiers.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -82,7 +82,7 @@ import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitP import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js'; import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js'; -import { CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { CopilotAgentStartupConfig } from './copilotAgentStartupConfig.js'; import { ShellManager } from './copilotShellTools.js'; import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; @@ -3693,6 +3693,13 @@ export class CopilotAgent extends Disposable implements IAgent { try { const resolvedAgent = provisional.isEphemeral ? undefined : await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); agent = resolvedAgent?.agent; + // Resolve the profile once and freeze it on the plan. The gate can flip while the session + // is provisional, or during the async launch below. + const autoTier = resolveCopilotAutoTier(provisional.model, this._configurationService, this._logService, sdkSessionId); + const model = provisional.model + ? this._withEffectiveAutoTier(provisional.model, autoTier, sdkSessionId, 'Auto routing profiles are unavailable') + : undefined; + provisional.model = model; const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client, @@ -3710,6 +3717,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: provisional.model, longContextWindow: this._longContextWindowFor(provisional.model?.id), freeLongContext: this._isFreeLongContext(provisional.model?.id), + autoTier, workspaceless: provisional.workspaceless, }; const chatChannelUri = this._findBoundSessionChatUri(sdkSessionId) ?? URI.parse(buildDefaultChatUri(sessionUri)); @@ -4189,6 +4197,8 @@ export class CopilotAgent extends Disposable implements IAgent { let launchPlan: CopilotSessionLaunchPlan; let sdkSessionId: string; let inheritedTurnId: string | undefined; + // Frozen before the async launch so the profile sent matches the one persisted below. + const forkAutoTier = resolveCopilotAutoTier(model, this._configurationService, this._logService, chatSdkId); let sourceEntry: CopilotAgentSession | undefined; if (fork) { sourceEntry = await this._ensureResolvedChatSession(this._resolveChatContext(fork.source, { configurationResource: forkSourceScope!, resource: this._resolveChatStorageScope(fork.source) })); @@ -4209,7 +4219,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, + fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id), autoTier: forkAutoTier }, }; } else { sdkSessionId = chatSdkId; @@ -4227,6 +4237,7 @@ export class CopilotAgent extends Disposable implements IAgent { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id), + autoTier: forkAutoTier, }; } @@ -4634,7 +4645,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) }, + fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id), autoTier: resolveCopilotAutoTier(info.model, this._configurationService, this._logService, context.sdkSessionId ?? context.configurationId) }, }; agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: configurationResource, chatChannelUri: chat, resource: context.resource }); await agentSession.initializeSession(); @@ -4740,29 +4751,33 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Rewrites a model selection so its Auto routing profile is the one `session` launched with. - * The runtime fixes the profile for a session's lifetime, so a requested change cannot be recorded. + * Rewrites a model selection so its Auto routing profile matches the one the runtime is actually + * using. Recording anything else would leave the picker and resume metadata out of step. */ - private _pinLaunchAutoTier(model: ModelSelection, session: CopilotAgentSession, sessionId: string): ModelSelection { + private _withEffectiveAutoTier(model: ModelSelection, effective: AutoModeTier | undefined, sessionId: string, reason: string): ModelSelection { // Only the Auto model routes per turn, so any other selection carries no profile to correct. if (!isAutoModel(model.id)) { return model; } const requested = model.config?.[AutoTierConfigKey]; - const launched = session.launchAutoTier; - if (requested === launched) { + if (requested === effective) { return model; } - this._logService.info(`[Copilot:${sessionId}] Auto routing profile is fixed for this session; keeping '${launched ?? 'the service default'}' instead of '${requested}'`); + this._logService.info(`[Copilot:${sessionId}] ${reason}; recording '${effective ?? 'the service default'}' instead of '${requested}'`); const config = { ...model.config }; - if (launched === undefined) { + if (effective === undefined) { delete config[AutoTierConfigKey]; } else { - config[AutoTierConfigKey] = launched; + config[AutoTierConfigKey] = effective; } return Object.keys(config).length > 0 ? { ...model, config } : { id: model.id }; } + /** The selection to record for a session that launched with `session`'s fixed routing profile. */ + private _pinLaunchAutoTier(model: ModelSelection, session: CopilotAgentSession, sessionId: string): ModelSelection { + return this._withEffectiveAutoTier(model, session.launchAutoTier, sessionId, 'Auto routing profile is fixed for this session'); + } + private async _changeAgent(chat: URI, agent: AgentSelection | undefined, operationContext: URI | IAgentChatContext): Promise { try { await this._changeAgentOnce(chat, agent, operationContext); @@ -5184,6 +5199,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: storedMetadata.model, longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), freeLongContext: this._isFreeLongContext(storedMetadata.model?.id), + autoTier: resolveCopilotAutoTier(storedMetadata.model, this._configurationService, this._logService, sessionId), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 92df62f545f212..b7e7540d2174bb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -56,7 +56,7 @@ import { ActionType, isChatAction, type ChatAction, type SessionAction } from '. import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, createErrorResponsePart, isSubagentSession, parseRequiredSessionUriFromChatUri, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, resolveCopilotAutoTier, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +import { clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME } from './toolSearchDeferral.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; @@ -3090,11 +3090,10 @@ export class CopilotAgentSession extends Disposable { /** * The Auto routing profile this session launched with, which the runtime fixes for the session's - * lifetime. `undefined` when the session routes with the service default. + * lifetime. Read from the frozen plan so a later gate flip cannot change what it reports. */ get launchAutoTier(): AutoModeTier | undefined { - const model = this._launchPlan.kind === 'create' ? this._launchPlan.model : this._launchPlan.fallback.model; - return resolveCopilotAutoTier(model, this._configurationService, this._logService, this.sessionId); + return this._launchPlan.kind === 'create' ? this._launchPlan.autoTier : this._launchPlan.fallback.autoTier; } async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier']): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 51a414ef8d80a4..199a721e8866e0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -272,6 +272,11 @@ export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBa readonly model: ModelSelection | undefined; readonly longContextWindow?: number; readonly freeLongContext?: boolean; + /** + * The Auto routing profile to send, already resolved against the gate. Frozen here so the profile + * the runtime receives always matches the one the caller persists. + */ + readonly autoTier?: AutoModeTier; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -281,6 +286,7 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly model: ModelSelection | undefined; readonly longContextWindow?: number; readonly freeLongContext?: boolean; + readonly autoTier?: AutoModeTier; }; } @@ -647,6 +653,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: fallbackPlan.fallback.model, longContextWindow: fallbackPlan.fallback.longContextWindow, freeLongContext: fallbackPlan.fallback.freeLongContext, + autoTier: fallbackPlan.fallback.autoTier, }, fallbackConfig, sandboxConfig); this._sessionOpenTelemetry.sdkResumeFallbackCreated(session); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); @@ -674,9 +681,9 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: plan.model?.id, reasoningEffort: resolveCopilotReasoningEffort(plan.model, this._configurationService, this._logService, plan.sessionId), contextTier: getCopilotContextTier(plan.model, plan.longContextWindow, plan.freeLongContext), - // Create-time only: the runtime then owns the profile, keeping it fixed while the session is - // resident and restoring it on cold resume. - ...toSdkCapiSessionOptions(resolveCopilotAutoTier(plan.model, this._configurationService, this._logService, plan.sessionId)), + // Create-time only. Taken from the plan rather than resolved again, so the profile sent + // always matches the one the caller persists. + ...toSdkCapiSessionOptions(plan.autoTier), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, })); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 0eb2f4a4954760..78b55f4fe2f34c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -10053,6 +10053,38 @@ suite('CopilotAgent', () => { } }); + test('drops a provisional Auto routing profile when the gate turns off before the first send', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([], [{ id: 'auto', name: 'Auto' }]); + client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + const { agent, configurationService } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: client, + rootConfig: { [CopilotCliConfigKey.AutoModeTiers]: true }, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, m => m.length > 0); + const session = AgentSession.uri('copilotcli', 'auto-tier-provisional'); + const chat = defaultChatUri(session); + const result = await provisionSession(agent, { + session, + workingDirectories: [URI.file('/workspace')], + model: { id: 'auto', config: { tier: 'intelligence' } }, + }); + + // Still provisional, so the launcher has not run. With the gate off it omits + // `capi.autoTier`, so persisting the selection would claim a profile never sent. + configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTiers]: false }); + await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); + + const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); + assert.deepStrictEqual(JSON.parse(stored ?? 'null'), { id: 'auto' }); + } finally { + await disposeAgent(agent); + } + }); + test('changeAgent resolves and applies the agent to the targeted chat, and clears it with undefined', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index cc704cbd21c927..1d5cb490ba50ae 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -22,7 +22,7 @@ import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPlugin import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels } from '../../common/reasoningEffort.js'; -import { autoModeTiers } from '../../common/autoModeTiers.js'; +import { autoModeTiers, type AutoModeTier } from '../../common/autoModeTiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { CustomizationType, McpServerStatus, type ClientPluginCustomization, type ModelSelection } from '../../common/state/protocol/state.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -1528,7 +1528,7 @@ suite('CopilotSessionLauncher auto tier', () => { ensureNoDisposablesAreLeakedInTestSuite(); /** Launches a session and returns the `capi` options the SDK was called with. */ - async function capiOptionsFor(kind: 'create' | 'resume', model: ModelSelection | undefined, rootValues: Partial>): Promise { + async function capiOptionsFor(kind: 'create' | 'resume', autoTier: AutoModeTier | undefined): Promise { let capi: unknown = 'not-called'; const session = { sessionId: 'session-1', @@ -1546,7 +1546,7 @@ suite('CopilotSessionLauncher auto tier', () => { return session; }, } as unknown as Pick; - const launcher = createTestLauncher(undefined, rootValues); + const launcher = createTestLauncher(); const base = { client, sessionId: 'session-1', @@ -1558,8 +1558,8 @@ suite('CopilotSessionLauncher auto tier', () => { githubToken: undefined, }; const plan: CopilotSessionLaunchPlan = kind === 'create' - ? { ...base, kind: 'create', model } - : { ...base, kind: 'resume', fallback: { model } }; + ? { ...base, kind: 'create', model: { id: 'auto' }, autoTier } + : { ...base, kind: 'resume', fallback: { model: { id: 'auto' }, autoTier } }; const sessions = new DisposableStore(); try { @@ -1571,21 +1571,17 @@ suite('CopilotSessionLauncher auto tier', () => { return capi; } - test('sends the profile on create only, and only while the gate is on', async () => { - const auto: ModelSelection = { id: 'auto', config: { tier: 'intelligence' } }; - + test('sends the plan profile verbatim on create, and never on resume', async () => { assert.deepStrictEqual( [ - await capiOptionsFor('create', auto, { [CopilotCliConfigKey.AutoModeTiers]: true }), - // Gate off: omitted entirely, so a runtime without the contract never sees the field. - await capiOptionsFor('create', auto, {}), - // No selection, and a profile left on a concrete model, both leave routing alone. - await capiOptionsFor('create', { id: 'auto' }, { [CopilotCliConfigKey.AutoModeTiers]: true }), - await capiOptionsFor('create', { id: 'gpt-5', config: { tier: 'intelligence' } }, { [CopilotCliConfigKey.AutoModeTiers]: true }), + // Sent exactly as frozen. The launcher must not resolve it again against the live gate. + await capiOptionsFor('create', 'intelligence'), + // No profile: omitted entirely, so a runtime without the contract never sees the field. + await capiOptionsFor('create', undefined), // Resume keeps whatever profile the runtime journaled for the session. - await capiOptionsFor('resume', auto, { [CopilotCliConfigKey.AutoModeTiers]: true }), + await capiOptionsFor('resume', 'intelligence'), ], - [{ autoTier: 'intelligence' }, undefined, undefined, undefined, undefined] + [{ autoTier: 'intelligence' }, undefined, undefined] ); }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts index 65448ea723cc19..23725580883dac 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts @@ -10,6 +10,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { AgentHostContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { AgentHostTerminalContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.js'; +import { AgentHostCopilotCliSettingsContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.js'; import { AgentHostAllowSignedOutWhenUsableContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAllowSignedOutWhenUsableContribution.js'; import { AgentHostSdkSetupNotificationContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSdkSetupNotification.js'; import { AgentHostSignedOutModelsNotificationContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSignedOutModelsNotification.js'; @@ -88,6 +89,9 @@ class LocalAgentHostContribution extends Disposable implements IWorkbenchContrib registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); +// Forwards the `chat.agentHost.copilot.*` settings into the host's root config. Without it those +// gates never reach the host here, so features like the Auto routing-profile picker stay off. +registerWorkbenchContribution2(AgentHostCopilotCliSettingsContribution.ID, AgentHostCopilotCliSettingsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostAllowSignedOutWhenUsableContribution.ID, AgentHostAllowSignedOutWhenUsableContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSignedOutModelsNotificationContribution.ID, AgentHostSignedOutModelsNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSdkSetupNotificationContribution.ID, AgentHostSdkSetupNotificationContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index 9cc0af6745f492..f645be3abd6832 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -11,7 +11,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ClientAnnotationsAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ConfigPropertySchema, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -77,6 +77,7 @@ const fullSchema: Record = { [CopilotCliConfigKey.ToolSearchDeferThreshold]: { type: 'number', title: 'Tool Search Defer Threshold' }, [CopilotCliConfigKey.ReasoningSummary]: { type: 'boolean', title: 'Reasoning Summary' }, [CopilotCliConfigKey.MultiTurnContextRouting]: { type: 'boolean', title: 'Auto Multi-Turn Context Routing' }, + [CopilotCliConfigKey.AutoModeTiers]: { type: 'boolean', title: 'Auto Routing Profiles' }, [CopilotCliConfigKey.SubagentModelGuidance]: { type: 'boolean', title: 'Subagent Model Guidance' }, [CopilotCliConfigKey.ModelCapabilityOverrides]: { type: 'object', title: 'Model Capability Overrides' }, }; @@ -122,6 +123,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [AgentHostCopilotModelCapabilityOverridesSettingId]: capabilityOverrides, [AgentHostReasoningSummaryEnabledSettingId]: true, [AgentHostMultiTurnContextRoutingEnabledSettingId]: true, + [AgentHostAutoModeTiersEnabledSettingId]: true, [CopilotSubagentModelGuidanceEnabledSettingId]: true, }); agentHostService.setRootState(makeRootStateWithSchema(fullSchema)); @@ -129,7 +131,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { // The shared forwarder dispatches one RootConfigChanged per key; merge them // and assert the full forwarded set (order-independent). - assert.strictEqual(agentHostService.dispatchedActions.length, 8); + assert.strictEqual(agentHostService.dispatchedActions.length, 9); const merged = Object.assign({}, ...agentHostService.dispatchedActions.map(a => (a.action as IRootConfigChangedAction).config)); assert.deepStrictEqual(merged, { [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace', @@ -138,6 +140,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.ToolSearchDeferThreshold]: 5, [CopilotCliConfigKey.ReasoningSummary]: true, [CopilotCliConfigKey.MultiTurnContextRouting]: true, + [CopilotCliConfigKey.AutoModeTiers]: true, [CopilotCliConfigKey.SubagentModelGuidance]: true, [CopilotCliConfigKey.ModelCapabilityOverrides]: capabilityOverrides, }); @@ -194,6 +197,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.ToolSearchDeferThreshold]: 1, [CopilotCliConfigKey.ReasoningSummary]: false, [CopilotCliConfigKey.MultiTurnContextRouting]: false, + [CopilotCliConfigKey.AutoModeTiers]: false, [CopilotCliConfigKey.SubagentModelGuidance]: false, [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, })); From c2d05a905fbb38fb9aed7ebbfc52f81222ac28ec Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:57:35 +0200 Subject: [PATCH 25/41] agentHost: Hide merge actions after successful merge (#333829) * agentHost: Hide merge actions after successful merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Reconcile pull request state sources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/state/sessionState.ts | 15 ++ .../node/agentHostGitStateService.ts | 6 +- ...ostPullRequestLifecycleOperationHandler.ts | 1 + .../node/agentHostPullRequestStatusService.ts | 100 ++++++++++++- .../test/node/agentHostContributions.test.ts | 1 + .../node/agentHostGitStateService.test.ts | 25 ++++ ...llRequestLifecycleOperationHandler.test.ts | 18 ++- ...ntHostPullRequestOperationProvider.test.ts | 10 +- .../agentHostPullRequestStatusService.test.ts | 135 +++++++++++++++++- .../changes/browser/changesViewService.ts | 18 ++- .../test/browser/changesViewService.test.ts | 36 ++++- .../github/browser/pullRequestIconStatus.ts | 5 +- .../browser/baseAgentHostSessionsProvider.ts | 12 +- .../test/browser/agentMergeActions.test.ts | 8 +- .../services/sessions/common/session.ts | 14 ++ 15 files changed, 371 insertions(+), 33 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 2740e9ce216742..1b54a6cbfb69a2 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1609,6 +1609,10 @@ export interface ISessionGitHubState { readonly initialPullRequestUrls?: readonly string[]; /** Pull requests explicitly associated through user intent, most recent first. */ readonly associatedPullRequestUrls?: readonly string[]; + /** Last host-observed state of {@link pullRequestStateUrl}. */ + readonly pullRequestState?: 'open' | 'closed' | 'merged'; + /** Pull request URL to which {@link pullRequestState} applies. */ + readonly pullRequestStateUrl?: string; /** * The name of the branch the most recent {@link pullRequestUrls} entry was found (or created) for. * A pull request always relates to a branch: when the working copy switches @@ -1663,10 +1667,15 @@ export function withMostRecentSessionPullRequest(gitHubState: ISessionGitHubStat pullRequestUrl, ...(gitHubState?.pullRequestUrls ?? []) ]); + const normalizedPullRequestUrl = pullRequestUrls[0]?.toLowerCase(); + const stateApplies = gitHubState?.pullRequestStateUrl?.toLowerCase() === normalizedPullRequestUrl; return { pullRequestUrls, pullRequestBranchName: branchName, + ...(stateApplies && gitHubState?.pullRequestState && gitHubState.pullRequestStateUrl + ? { pullRequestState: gitHubState.pullRequestState, pullRequestStateUrl: gitHubState.pullRequestStateUrl } + : {}), }; } @@ -1799,6 +1808,8 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS pullRequestUrls?: readonly string[]; initialPullRequestUrls?: readonly string[]; associatedPullRequestUrls?: readonly string[]; + pullRequestState?: 'open' | 'closed' | 'merged'; + pullRequestStateUrl?: string; pullRequestBranchName?: string; } = {}; @@ -1821,6 +1832,10 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS result.associatedPullRequestUrls = associatedPullRequestUrls; } } + if (raw['pullRequestState'] === 'open' || raw['pullRequestState'] === 'closed' || raw['pullRequestState'] === 'merged') { + result.pullRequestState = raw['pullRequestState']; + } + if (typeof raw['pullRequestStateUrl'] === 'string') { result.pullRequestStateUrl = raw['pullRequestStateUrl']; } if (typeof raw['pullRequestBranchName'] === 'string') { result.pullRequestBranchName = raw['pullRequestBranchName']; } return result; } diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index a4a96d461cbd58..72eca2b6813812 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -274,9 +274,13 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; const currentState = readSessionGitHubState(currentMeta); - const nextState = { ...(currentState ?? {}), ...state } satisfies ISessionGitHubState; + let nextState = { ...(currentState ?? {}), ...state } satisfies ISessionGitHubState; const currentPullRequest = getSessionRelatedPullRequestUrls(currentState)[0]; const nextPullRequest = getSessionRelatedPullRequestUrls(nextState)[0]; + if (currentPullRequest !== nextPullRequest && state.pullRequestStateUrl === undefined) { + const { pullRequestState: _ignoredState, pullRequestStateUrl: _ignoredStateUrl, ...stateWithoutPullRequestStatus } = nextState; + nextState = stateWithoutPullRequestStatus; + } const currentSourceControlState = readSessionSourceControlState(currentMeta); const nextSourceControlState = nextPullRequest && nextPullRequest !== currentPullRequest ? { ...currentSourceControlState, latestOutcome: SessionSourceControlOutcome.PullRequest } satisfies ISessionSourceControlState diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts index 48e26fb4ead7cc..c1602c25d3b51d 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts @@ -147,6 +147,7 @@ export class AgentHostPullRequestLifecycleOperationHandler implements IChangeset const method = this._requireMergeMethod(mergeability?.allowedMergeMethods ?? []); const result = await this._gitHubService.mutations.merge(preparation, { method, authorization }, signal); + this._statusService.markPullRequestMerged(sessionUri, status.url); this._logService.info(`[AgentHostPullRequestLifecycleOperationHandler] Pull request merged: session=${sessionUri}, pr=${status.url}, method=${method}, outcome=${result.outcome}`); return localize('agentHost.changeset.pr.merged', "Pull request was merged."); } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts index 8079f47406210f..e7a11c311b4c1a 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts @@ -74,6 +74,9 @@ export interface IAgentHostPullRequestStatusService extends IDisposable { */ getPullRequestStatus(sessionKey: string): IAgentHostPullRequestStatus | undefined; + /** Records a successful direct merge before the next GitHub refresh completes. */ + markPullRequestMerged(sessionKey: string, pullRequestUrl: string): void; + /** * Re-reads the pull request from GitHub, bypassing cached fragments. Used * after a mutation so the advertised operations reflect the new state @@ -85,6 +88,7 @@ export interface IAgentHostPullRequestStatusService extends IDisposable { interface IWatch extends IDisposable { readonly ref: PullRequestRef; readonly subscription: PullRequestSubscription; + awaitingAuthoritativeRefresh: boolean; status?: IAgentHostPullRequestStatus; } @@ -154,6 +158,32 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg return this._watches.get(sessionKey)?.status; } + markPullRequestMerged(sessionKey: string, pullRequestUrl: string): void { + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + const currentPullRequestUrl = getSessionRelatedPullRequestUrls(gitHubState)[0] ?? gitHubState?.pullRequestUrls?.[0]; + const mergedPullRequest = parsePullRequestUrl(pullRequestUrl); + const currentPullRequest = currentPullRequestUrl ? parsePullRequestUrl(currentPullRequestUrl) : undefined; + if (!mergedPullRequest || !currentPullRequest || !sameParsedRef(mergedPullRequest, currentPullRequest)) { + return; + } + + const watch = this._watches.get(sessionKey); + const status = watch?.status; + if (!watch || !status || !sameRefAndHost(watch.ref, mergedPullRequest)) { + this._publishPullRequestState(sessionKey, pullRequestUrl, 'merged'); + this._onDidChangePullRequestStatus.fire(sessionKey); + return; + } + this._setStatus(sessionKey, watch, { + ...status, + state: 'merged', + draft: false, + mergeReady: false, + viewerCanEnableAutoMerge: false, + autoMergeEnabled: false, + }); + } + async refresh(sessionKey: string): Promise { const watch = this._watches.get(sessionKey); if (!watch) { @@ -228,7 +258,7 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg } const existing = this._watches.get(sessionKey); - if (existing && sameRef(existing.ref, parsed)) { + if (existing && sameRefAndHost(existing.ref, parsed)) { return; } @@ -266,16 +296,45 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg const watch: IWatch = { ref, subscription, + awaitingAuthoritativeRefresh: this._hasPersistedMergedState(sessionKey, ref), dispose: () => store.dispose(), }; this._watches.set(sessionKey, watch); store.add(autorun(reader => { const snapshot = subscription.resource.snapshot.read(reader); + if (watch.awaitingAuthoritativeRefresh) { + return; + } this._updateStatus(sessionKey, watch, snapshot); })); + if (watch.awaitingAuthoritativeRefresh) { + void this._refreshRecreatedMergedWatch(sessionKey, watch); + } this._logService.debug(`[AgentHostPullRequestStatusService] Watching pull request: session=${sessionKey}, pr=${describeRef(ref)}`); } + private _hasPersistedMergedState(sessionKey: string, ref: PullRequestRef): boolean { + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + const persistedPullRequest = gitHubState?.pullRequestStateUrl ? parsePullRequestUrl(gitHubState.pullRequestStateUrl) : undefined; + return gitHubState?.pullRequestState === 'merged' + && persistedPullRequest !== undefined + && sameRefAndHost(ref, persistedPullRequest); + } + + private async _refreshRecreatedMergedWatch(sessionKey: string, watch: IWatch): Promise { + try { + await watch.subscription.refresh(undefined, undefined, { authoritative: true }); + } catch (error) { + this._logService.warn(`[AgentHostPullRequestStatusService] Failed to refresh recreated merged pull request watch: session=${sessionKey}, pr=${describeRef(watch.ref)}, error=${error}`); + return; + } + if (this._watches.get(sessionKey) !== watch) { + return; + } + watch.awaitingAuthoritativeRefresh = false; + this._updateStatus(sessionKey, watch, watch.subscription.resource.snapshot.get()); + } + /** * The pull request this session should be watching, or the reason it is not * eligible. The reason is carried rather than collapsed into `undefined` so @@ -304,7 +363,18 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg } private _updateStatus(sessionKey: string, watch: IWatch, snapshot: PullRequestSnapshot): void { - const status = toPullRequestStatus(snapshot); + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + const persistedPullRequest = gitHubState?.pullRequestStateUrl ? parsePullRequestUrl(gitHubState.pullRequestStateUrl) : undefined; + const persistedMergedStateApplies = gitHubState?.pullRequestState === 'merged' + && persistedPullRequest !== undefined + && sameRefAndHost(watch.ref, persistedPullRequest); + if (snapshot.core.status !== 'ready' && (watch.status?.state === 'merged' || persistedMergedStateApplies)) { + return; + } + this._setStatus(sessionKey, watch, toPullRequestStatus(snapshot)); + } + + private _setStatus(sessionKey: string, watch: IWatch, status: IAgentHostPullRequestStatus | undefined): void { if (structuralEquals(watch.status, status)) { return; } @@ -316,9 +386,23 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg // The single most useful line when a button bar shows the "wrong" // action: it names every flag the operation provider branches on. this._logService.debug(`[AgentHostPullRequestStatusService] Status changed: session=${sessionKey}, pr=${describeRef(watch.ref)}, from=[${describeStatus(previous)}], to=[${describeStatus(status)}]`); + if (status) { + this._publishPullRequestState(sessionKey, status.url, status.state); + } this._onDidChangePullRequestStatus.fire(sessionKey); } + private _publishPullRequestState(sessionKey: string, pullRequestUrl: string, state: IAgentHostPullRequestStatus['state']): void { + const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); + if (gitHubState?.pullRequestState === state && gitHubState.pullRequestStateUrl === pullRequestUrl) { + return; + } + void this._gitStateService.setSessionGitHubState(sessionKey, { + pullRequestState: state, + pullRequestStateUrl: pullRequestUrl, + }).catch(error => this._logService.warn(`[AgentHostPullRequestStatusService] Failed to publish pull request state: session=${sessionKey}, pr=${pullRequestUrl}, state=${state}, error=${error}`)); + } + private _stopWatch(sessionKey: string, reason?: string): void { const watch = this._watches.get(sessionKey); if (!watch) { @@ -401,12 +485,22 @@ function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullReque }; } -function sameRef(left: PullRequestRef, right: { readonly owner: string; readonly repo: string; readonly number: number }): boolean { +function sameRef(left: { readonly owner: string; readonly repo: string; readonly number: number }, right: { readonly owner: string; readonly repo: string; readonly number: number }): boolean { return left.owner.toLowerCase() === right.owner.toLowerCase() && left.repo.toLowerCase() === right.repo.toLowerCase() && left.number === right.number; } +type ParsedPullRequestUrl = NonNullable>; + +function sameParsedRef(left: ParsedPullRequestUrl, right: ParsedPullRequestUrl): boolean { + return left.apiHost.toLowerCase() === right.apiHost.toLowerCase() && sameRef(left, right); +} + +function sameRefAndHost(left: PullRequestRef, right: ParsedPullRequestUrl): boolean { + return left.host.toLowerCase() === right.apiHost.toLowerCase() && sameRef(left, right); +} + function sameAccount(left: GitHubAccountHandle, right: GitHubAccountHandle): boolean { return left.host.toLowerCase() === right.host.toLowerCase() && left.accountId === right.accountId; } diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts index c19d4f2cd81759..a88d4f16ea762e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -54,6 +54,7 @@ const nullPullRequestStatusService: IAgentHostPullRequestStatusService = { _serviceBrand: undefined, onDidChangePullRequestStatus: Event.None, getPullRequestStatus() { return undefined; }, + markPullRequestMerged() { }, async refresh() { }, dispose() { }, }; diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 92354f49343f3b..7f413d001aa00d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -84,6 +84,31 @@ suite('AgentHostGitStateService', () => { }); }); + test('keeps pull request state scoped to its pull request', () => { + const pullRequest = 'https://github.com/microsoft/vscode/pull/1'; + const state: ISessionGitHubState = { + pullRequestUrls: [pullRequest], + pullRequestState: 'merged', + pullRequestStateUrl: pullRequest, + }; + + assert.deepStrictEqual({ + same: withMostRecentSessionPullRequest(state, `${pullRequest}/`, 'feature-1'), + different: withMostRecentSessionPullRequest(state, 'https://github.com/microsoft/vscode/pull/2', 'feature-2'), + }, { + same: { + pullRequestUrls: [pullRequest], + pullRequestBranchName: 'feature-1', + pullRequestState: 'merged', + pullRequestStateUrl: pullRequest, + }, + different: { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/2', pullRequest], + pullRequestBranchName: 'feature-2', + }, + }); + }); + test('promotes an initial pull request into the session', () => { const initial = 'https://github.com/microsoft/vscode/pull/1'; const state = withMostRecentRelatedSessionPullRequest({ diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts index 480307d88f5e00..fe9d85a07f4171 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestLifecycleOperationHandler.test.ts @@ -74,9 +74,10 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { readonly prepareMergeError?: Error; readonly mergeMethod?: string; }, - ): { readonly handler: AgentHostPullRequestLifecycleOperationHandler; readonly recorded: IRecordedCalls; readonly refreshes: string[] } { + ): { readonly handler: AgentHostPullRequestLifecycleOperationHandler; readonly recorded: IRecordedCalls; readonly refreshes: string[]; readonly merged: string[] } { const recorded: IRecordedCalls = { calls: [] }; const refreshes: string[] = []; + const merged: string[] = []; const currentStatus = options?.status === null ? undefined : options?.status ?? status(); const mutations = new class extends mock() { @@ -116,6 +117,7 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { _serviceBrand: undefined, onDidChangePullRequestStatus: Event.None, getPullRequestStatus: () => currentStatus, + markPullRequestMerged: (sessionKey, url) => { merged.push(`${sessionKey}|${url}`); }, refresh: async (sessionKey: string) => { refreshes.push(sessionKey); }, dispose: () => { }, }; @@ -133,7 +135,7 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { gitHubService, new NullLogService(), ); - return { handler, recorded, refreshes }; + return { handler, recorded, refreshes, merged }; } function invoke(handler: AgentHostPullRequestLifecycleOperationHandler): Promise { @@ -141,24 +143,28 @@ suite('AgentHostPullRequestLifecycleOperationHandler', () => { } test('merges directly with the repository-allowed method', async () => { - const { handler, recorded, refreshes } = createHandler('merge'); + const { handler, recorded, refreshes, merged } = createHandler('merge'); disposables.add({ dispose: () => { } }); await invoke(handler); // The preparation gate runs before the merge, and the status is // refreshed afterwards so the button bar re-derives. - assert.deepStrictEqual({ calls: recorded.calls, refreshed: refreshes.length }, { calls: ['prepareMerge', 'merge:SQUASH'], refreshed: 1 }); + assert.deepStrictEqual({ calls: recorded.calls, refreshed: refreshes.length, merged }, { + calls: ['prepareMerge', 'merge:SQUASH'], + refreshed: 1, + merged: [`${sessionUri}|${pullRequestUrl}`], + }); }); test('enqueues instead of merging when the repository requires a merge queue', async () => { - const { handler, recorded } = createHandler('merge', { + const { handler, recorded, merged } = createHandler('merge', { preparation: preparation({ mergeQueueRequired: true }), }); await invoke(handler); - assert.deepStrictEqual(recorded.calls, ['prepareMerge', 'enqueue']); + assert.deepStrictEqual({ calls: recorded.calls, merged }, { calls: ['prepareMerge', 'enqueue'], merged: [] }); }); test('honours the configured merge method and rejects one the repository forbids', async () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index ab2c654bf4a3c8..a96e11196de22b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -28,11 +28,12 @@ const nullGitStateService = new class implements IAgentHostGitStateService { async attachSessionGitHubPullRequest(): Promise { } }; -function createStatusService(status?: IAgentHostPullRequestStatus): IAgentHostPullRequestStatusService { +function createStatusService(status?: IAgentHostPullRequestStatus, onDidChangePullRequestStatus = Event.None): IAgentHostPullRequestStatusService { return { _serviceBrand: undefined, - onDidChangePullRequestStatus: Event.None, + onDidChangePullRequestStatus, getPullRequestStatus: () => status, + markPullRequestMerged: () => { }, refresh: async () => { }, dispose: () => { }, }; @@ -69,7 +70,7 @@ const pullRequestForBranch: ISessionGitHubState = { suite('AgentHostPullRequestOperationContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree'): AgentHostPullRequestOperationContribution { + function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None): AgentHostPullRequestOperationContribution { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); if (isolation) { stateManager.createSession({ @@ -90,7 +91,7 @@ suite('AgentHostPullRequestOperationContribution', () => { stateManager, disposables.add(new InstantiationService()), nullGitStateService, - createStatusService(status), + createStatusService(status, onDidChangePullRequestStatus), new NullLogService(), )); } @@ -162,4 +163,5 @@ suite('AgentHostPullRequestOperationContribution', () => { noAutoMerge: undefined, }); }); + }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts index b14cdece213ae5..6d794cc64d4b81 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts @@ -16,7 +16,7 @@ import type { IPullRequestResources } from '../../../github/common/pullRequestRe import { mock } from '../../../../base/test/common/mock.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; -import { SessionStatus, withSessionGitHubState, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { readSessionGitHubState, SessionStatus, withSessionGitHubState, withSessionGitState, type ISessionGitHubState, type SessionSummary } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; @@ -83,20 +83,31 @@ class TestPullRequestResources implements IPullRequestResources { readonly subscribed: PullRequestRef[] = []; disposedCount = 0; private _snapshot = observableValue('snapshot', undefined); + private _nextSubscriptionSnapshot: PullRequestSnapshot | undefined; + refreshHandler: (() => Promise) | undefined; get liveSubscriptions(): number { return this.subscribed.length - this.disposedCount; } subscribePullRequest(ref: PullRequestRef): PullRequestSubscription { this.subscribed.push(ref); - this._snapshot.set(snapshot(ref), undefined); + this._snapshot.set(this._nextSubscriptionSnapshot ?? snapshot(ref), undefined); + this._nextSubscriptionSnapshot = undefined; return { resource: { ref, snapshot: this._snapshot as never }, update: () => { }, - refresh: async () => { }, + refresh: async () => this.refreshHandler?.(), dispose: () => { this.disposedCount++; }, } as PullRequestSubscription; } + setSnapshot(value: PullRequestSnapshot): void { + this._snapshot.set(value, undefined); + } + + setNextSubscriptionSnapshot(value: PullRequestSnapshot): void { + this._nextSubscriptionSnapshot = value; + } + invalidatePullRequest(): void { } clear(): void { } } @@ -180,6 +191,7 @@ suite('AgentHostPullRequestStatusService', () => { const subscriptions = disposables.add(new TestChangesetSubscriptions()); const credentials = disposables.add(new TestCredentials()); const resources = new TestPullRequestResources(); + const gitHubStates: ISessionGitHubState[] = []; const gitHubService = new class extends mock() { override readonly credentials = credentials; override readonly pullRequests = resources; @@ -187,6 +199,14 @@ suite('AgentHostPullRequestStatusService', () => { const gitStateService = new class extends mock() { override readonly onDidRefreshSessionGitState = Event.None; override readonly onDidChangeSessionGitHubState = Event.None; + override async setSessionGitHubState(sessionKey: string, state: ISessionGitHubState): Promise { + gitHubStates.push(state); + const currentMeta = stateManager.getSessionState(sessionKey)?._meta; + stateManager.setSessionMeta(sessionKey, withSessionGitHubState(currentMeta, { + ...readSessionGitHubState(currentMeta), + ...state, + })); + } }(); const service = disposables.add(new AgentHostPullRequestStatusService( stateManager, @@ -205,7 +225,7 @@ suite('AgentHostPullRequestStatusService', () => { { pullRequestUrls: [pullRequestUrl], pullRequestBranchName: 'feature' }, )); - return { service, stateManager, subscriptions, credentials, resources, session }; + return { service, stateManager, subscriptions, credentials, resources, gitHubStates, session }; } test('watches only while a client is subscribed to the session changes', async () => { @@ -231,6 +251,113 @@ suite('AgentHostPullRequestStatusService', () => { }); }); + test('optimistically records a successful merge in host pull request state', async () => { + const { service, subscriptions, resources, gitHubStates, session } = createHarness(); + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + + service.markPullRequestMerged(session, pullRequestUrl); + + assert.deepStrictEqual({ + status: service.getPullRequestStatus(session)?.state, + gitHubState: gitHubStates.at(-1), + }, { + status: 'merged', + gitHubState: { + pullRequestState: 'merged', + pullRequestStateUrl: pullRequestUrl, + }, + }); + }); + + test('records a successful merge after its pull request watch was disposed', async () => { + const { service, gitHubStates, session } = createHarness(); + + service.markPullRequestMerged(session, pullRequestUrl); + + assert.deepStrictEqual({ + status: service.getPullRequestStatus(session), + gitHubState: gitHubStates.at(-1), + }, { + status: undefined, + gitHubState: { + pullRequestState: 'merged', + pullRequestStateUrl: pullRequestUrl, + }, + }); + }); + + test('does not downgrade a merged pull request from a retained loading snapshot', async () => { + const { service, subscriptions, resources, session } = createHarness(); + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + service.markPullRequestMerged(session, pullRequestUrl); + + const retainedOpenSnapshot = snapshot(resources.subscribed[0]); + resources.setSnapshot({ + ...retainedOpenSnapshot, + core: { ...retainedOpenSnapshot.core, status: 'loading', complete: false }, + }); + const whileLoading = service.getPullRequestStatus(session)?.state; + resources.setSnapshot(retainedOpenSnapshot); + + assert.deepStrictEqual({ + whileLoading, + afterRefresh: service.getPullRequestStatus(session)?.state, + }, { + whileLoading: 'merged', + afterRefresh: 'open', + }); + }); + + test('waits for a fresh refresh before reconciling a recreated merged watch', async () => { + const { service, subscriptions, resources, session } = createHarness(); + const channel = `${session}/changes`; + subscriptions.addSubscription(session, channel); + await waitForWatch(resources); + service.markPullRequestMerged(session, pullRequestUrl); + subscriptions.removeSubscription(session, channel); + + const refresh = new DeferredPromise(); + resources.refreshHandler = () => refresh.p; + subscriptions.addSubscription(session, channel); + await waitForWatch(resources); + const whileRefreshing = service.getPullRequestStatus(session); + refresh.complete(); + await pump(); + + assert.deepStrictEqual({ + whileRefreshing, + afterRefresh: service.getPullRequestStatus(session)?.state, + }, { + whileRefreshing: undefined, + afterRefresh: 'open', + }); + }); + + test('does not apply persisted merged state from another GitHub host', async () => { + const { service, stateManager, subscriptions, resources, session } = createHarness(); + const retainedOpenSnapshot = snapshot({ ...account, owner: 'octo', repo: 'repo', number: 7 }); + resources.setNextSubscriptionSnapshot({ + ...retainedOpenSnapshot, + core: { ...retainedOpenSnapshot.core, status: 'loading', complete: false }, + }); + stateManager.setSessionMeta(session, withSessionGitHubState( + stateManager.getSessionState(session)?._meta, + { + pullRequestUrls: [pullRequestUrl], + pullRequestBranchName: 'feature', + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.example.com/octo/repo/pull/7', + }, + )); + + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + + assert.strictEqual(service.getPullRequestStatus(session)?.state, 'open'); + }); + test('does not install a watch when the session stopped being eligible mid-sync', async () => { const { service, subscriptions, credentials, resources, session } = createHarness(); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 64f832e65b3e89..3ed3018713496e 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -379,11 +379,19 @@ export class ChangesViewService extends Disposable implements IChangesViewServic // Pull request state const gitHubInfo = gitRepository?.gitHubInfo.read(reader); const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; - const hasOpenPullRequest = hasPullRequest && - (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || - gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id || - gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestError.id || - gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestComment.id); + const hostPullRequestState = gitHubInfo?.pullRequest?.state; + const livePullRequestState = gitHubInfo?.pullRequest?.liveState; + const hasTerminalPullRequestState = hostPullRequestState === 'closed' + || hostPullRequestState === 'merged' + || livePullRequestState === 'closed' + || livePullRequestState === 'merged'; + const hasOpenPullRequest = hasPullRequest && !hasTerminalPullRequestState + && (hostPullRequestState === 'open' + || livePullRequestState === 'open' + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestError.id + || gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestComment.id); // Repository state const hasGitHubRemote = gitRepository?.hasGitHubRemote ?? false; diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts index 8ecd4e026c284c..926bfc3f331519 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -23,8 +24,8 @@ suite('ChangesViewService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createSession(id: string, options?: { readonly changesets?: readonly ISessionChangeset[]; readonly baseBranchProtected?: boolean }): IActiveSession { - const workspace = options?.baseBranchProtected === undefined + function createSession(id: string, options?: { readonly changesets?: readonly ISessionChangeset[]; readonly baseBranchProtected?: boolean; readonly pullRequestState?: 'open' | 'closed' | 'merged'; readonly livePullRequestState?: 'open' | 'closed' | 'merged'; readonly pullRequestIcon?: { readonly id: string } }): IActiveSession { + const workspace = options?.baseBranchProtected === undefined && options?.pullRequestState === undefined && options?.livePullRequestState === undefined && options?.pullRequestIcon === undefined ? undefined : upcastPartial({ folders: [upcastPartial({ @@ -35,7 +36,17 @@ suite('ChangesViewService', () => { workTreeUri: URI.file('/repo.worktrees/session'), baseBranchName: 'main', baseBranchProtected: options.baseBranchProtected, - gitHubInfo: constObservable(undefined), + gitHubInfo: constObservable(options.pullRequestState || options.livePullRequestState || options.pullRequestIcon ? { + owner: 'microsoft', + repo: 'vscode', + pullRequest: { + number: 1, + uri: URI.parse('https://github.com/microsoft/vscode/pull/1'), + icon: options.pullRequestIcon ?? Codicon.gitPullRequest, + state: options.pullRequestState, + liveState: options.livePullRequestState, + }, + } : undefined), }), })], }); @@ -44,6 +55,7 @@ suite('ChangesViewService', () => { providerId: 'local-agent-host', sessionType: 'test', loading: constObservable(false), + changes: constObservable([]), changesets: constObservable(options?.changesets ?? []), workspace: constObservable(workspace), }); @@ -337,4 +349,22 @@ suite('ChangesViewService', () => { ['merge', 'create-pr'], ]); }); + + test('reconciles host pull request state with the live icon', () => { + const openSession = createSession('open', { pullRequestState: 'open' }); + const mergedSession = createSession('merged', { pullRequestState: 'merged', livePullRequestState: 'open' }); + const cachedTerminalSession = createSession('cached-terminal', { pullRequestState: 'open', pullRequestIcon: Codicon.gitPullRequestDone }); + const liveTerminalSession = createSession('live-terminal', { pullRequestState: 'open', livePullRequestState: 'merged', pullRequestIcon: Codicon.gitPullRequestDone }); + const { activeSession, service } = createHarness(openSession); + + const hasOpenPullRequest = [service.activeSessionStateObs.get()?.hasOpenPullRequest]; + activeSession.set(mergedSession, undefined); + hasOpenPullRequest.push(service.activeSessionStateObs.get()?.hasOpenPullRequest); + activeSession.set(cachedTerminalSession, undefined); + hasOpenPullRequest.push(service.activeSessionStateObs.get()?.hasOpenPullRequest); + activeSession.set(liveTerminalSession, undefined); + hasOpenPullRequest.push(service.activeSessionStateObs.get()?.hasOpenPullRequest); + + assert.deepStrictEqual(hasOpenPullRequest, [true, false, true, false]); + }); }); diff --git a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts index f789606205980d..32d7f44126418e 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts @@ -41,7 +41,7 @@ export function computeLivePullRequestIcon(reader: IReaderWithStore, gitHubServi } /** Computes the live title and icon used to present a pull request reference. */ -export function computePullRequestRefPresentation(reader: IReaderWithStore, gitHubService: IGitHubService, iconCache: IPullRequestIconCache, pullRequest: IGitHubPullRequestRef, fallbackIcon?: ThemeIcon): Pick { +export function computePullRequestRefPresentation(reader: IReaderWithStore, gitHubService: IGitHubService, iconCache: IPullRequestIconCache, pullRequest: IGitHubPullRequestRef, fallbackIcon?: ThemeIcon): Pick { const prLink = pullRequest.uri.toString(); const prModelRef = reader.store.add(gitHubService.createPullRequestModelReference(pullRequest.owner, pullRequest.repo, pullRequest.number)); const livePullRequest = prModelRef.object.pullRequest.read(reader); @@ -49,10 +49,11 @@ export function computePullRequestRefPresentation(reader: IReaderWithStore, gitH return { icon: iconCache.get(prLink) ?? pullRequest.icon ?? fallbackIcon, title: pullRequest.title, + liveState: undefined, }; } const icon = computeLivePullRequestIcon(reader, gitHubService, pullRequest.owner, pullRequest.repo, livePullRequest); iconCache.set(prLink, icon); - return { icon, title: livePullRequest.title }; + return { icon, title: livePullRequest.title, liveState: livePullRequest.state }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 11e22c2a65432f..4b1c66b6ebfeb7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -318,9 +318,13 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine x.number === y.number && isEqual(x.uri, y.uri) && x.icon?.id === y.icon?.id && + x.state === y.state && + x.liveState === y.liveState && x.title === y.title) && a.pullRequest?.number === b.pullRequest?.number && a.pullRequest?.icon?.id === b.pullRequest?.icon?.id && + a.pullRequest?.state === b.pullRequest?.state && + a.pullRequest?.liveState === b.pullRequest?.liveState && a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && a.pullRequest?.headRefOid === b.pullRequest?.headRefOid && @@ -354,7 +358,7 @@ function toGitHubIssueRefs(issueUrls: readonly string[] | undefined): readonly I * title. Every pull request published here belongs to the session — it either * produced it or its branch relates to it — so all are marked as such. */ -function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap): readonly IGitHubPullRequestRef[] | undefined { +function toGitHubPullRequestRefs(state: ISessionGitHubState | undefined, pullRequestUrls: readonly string[] | undefined, titles: ReadonlyMap): readonly IGitHubPullRequestRef[] | undefined { const refs: IGitHubPullRequestRef[] = []; for (const url of pullRequestUrls ?? []) { const reference = parseGitHubPullRequestUrl(url); @@ -363,6 +367,7 @@ function toGitHubPullRequestRefs(pullRequestUrls: readonly string[] | undefined, refs.push({ ...reference, uri: URI.parse(url), + state: state?.pullRequestStateUrl && linkKey(state.pullRequestStateUrl) === linkKey(url) ? state.pullRequestState : undefined, ...(title ? { title } : {}), createdByThisSession: true, }); @@ -387,7 +392,7 @@ function toGitHubPromotion(meta: SessionMeta | undefined): IGitHubPromotion { // Only pull requests the session produced are promoted, so the ones it // recorded lead the discovered ones and the first is the main pull request. - const allPullRequests = toGitHubPullRequestRefs(dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles); + const allPullRequests = toGitHubPullRequestRefs(state, dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles); const repository = state?.owner && state.repo ? { owner: state.owner, repo: state.repo } : gitState?.githubOwner && gitState.githubRepo @@ -423,6 +428,7 @@ function toGitHubPromotion(meta: SessionMeta | undefined): IGitHubPromotion { pullRequest: pullRequest ? { number: pullRequest.number, uri: pullRequest.uri, + state: pullRequest.state, } : undefined, issues: issues?.length ? issues : undefined, }, @@ -996,6 +1002,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { ) })); const icon = pullRequests[0].icon; + const liveState = pullRequests[0].liveState; const title = pullRequests[0].title; return { ...baseGitHubInfo, @@ -1003,6 +1010,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { pullRequest: { ...baseGitHubInfo.pullRequest, icon, + liveState, title, } }; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts index 5e1a43de9a53a7..09cdfebe927c85 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts @@ -19,20 +19,20 @@ suite('Agent Merge Actions', () => { ensureNoDisposablesAreLeakedInTestSuite(); /** A session where Agent Merge applies: agent host provider, live, with an open pull request. */ - function createContext(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean }): Context { + function createContext(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean; readonly hasOpenPullRequest?: boolean }): Context { const context = new Context(1, null); context.setValue(IsSessionsWindowContext.key, true); context.setValue(ChatContextKeys.enabled.key, true); context.setValue(SessionProviderIdContext.key, 'local-agent-host'); context.setValue(SessionIsArchivedContext.key, options.archived ?? false); context.setValue(`config.${AgentMergeSettingId.Enabled}`, options.featureEnabled ?? true); - context.setValue(SessionHasOpenPullRequestContext.key, true); + context.setValue(SessionHasOpenPullRequestContext.key, options.hasOpenPullRequest ?? true); context.setValue(SessionPrimaryPullRequestOperationContext.key, options.primaryOperation); context.setValue(SessionAgentMergeEnabledContext.key, options.agentMergeEnabled); return context; } - function ownsPrimaryButton(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean }): boolean { + function ownsPrimaryButton(options: { readonly primaryOperation: string; readonly agentMergeEnabled: boolean; readonly featureEnabled?: boolean; readonly archived?: boolean; readonly hasOpenPullRequest?: boolean }): boolean { const item = MenuRegistry.getMenuItems(Menus.ChangesOperationsDropdown) .find(entry => !isIMenuItem(entry) && entry.submenu === Menus.ChangesAgentMerge); assert.ok(item, 'Agent Merge is contributed to the changes operations dropdown'); @@ -76,9 +76,11 @@ suite('Agent Merge Actions', () => { assert.deepStrictEqual({ featureOff: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.EnableAutoMerge, agentMergeEnabled: false, featureEnabled: false }), archived: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.EnableAutoMerge, agentMergeEnabled: true, archived: true }), + mergeCompleted: ownsPrimaryButton({ primaryOperation: '', agentMergeEnabled: true, hasOpenPullRequest: false }), }, { featureOff: false, archived: false, + mergeCompleted: false, }); }); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 08b8004ed15602..0c648fb4f4c8a1 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -304,6 +304,10 @@ export interface IGitHubInfo { readonly number: number; /** URI of the pull request. */ readonly uri: URI; + /** Last host-observed pull request state. */ + readonly state?: 'open' | 'closed' | 'merged'; + /** State from the live workbench pull request model, when resolved. */ + readonly liveState?: 'open' | 'closed' | 'merged'; /** Icon reflecting the PR state. */ readonly icon?: ThemeIcon; /** Pull request title, when known. */ @@ -332,6 +336,10 @@ export interface IGitHubPullRequestRef { readonly uri: URI; /** Icon reflecting the last known PR state. */ readonly icon?: ThemeIcon; + /** Last host-observed pull request state. */ + readonly state?: 'open' | 'closed' | 'merged'; + /** State from the live workbench pull request model, when resolved. */ + readonly liveState?: 'open' | 'closed' | 'merged'; /** * Pull request title, when the session recorded one. Absent for pull requests * discovered from git state, which carry no title until they are fetched live. @@ -358,6 +366,8 @@ export function getGitHubPullRequestRefs(gitHubInfo: IGitHubInfo | undefined): r number: gitHubInfo.pullRequest.number, uri: gitHubInfo.pullRequest.uri, icon: gitHubInfo.pullRequest.icon, + state: gitHubInfo.pullRequest.state, + liveState: gitHubInfo.pullRequest.liveState, title: gitHubInfo.pullRequest.title, }]; } @@ -1018,11 +1028,15 @@ export function gitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | und x.repo === y.repo && x.number === y.number && isEqual(x.uri, y.uri) && + x.state === y.state && + x.liveState === y.liveState && x.title === y.title && x.createdByThisSession === y.createdByThisSession && (x.icon === y.icon || (!!x.icon && !!y.icon && ThemeIcon.isEqual(x.icon, y.icon)))) && a.pullRequest?.number === b.pullRequest?.number && isEqual(a.pullRequest?.uri, b.pullRequest?.uri) && + a.pullRequest?.state === b.pullRequest?.state && + a.pullRequest?.liveState === b.pullRequest?.liveState && (aIcon === bIcon || (!!aIcon && !!bIcon && ThemeIcon.isEqual(aIcon, bIcon))) && a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && From 5a60c4adaee7c072e1c20c29d25e8ea7dddca7bc Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:57:45 -0700 Subject: [PATCH 26/41] POC: Chat session state frames (#332799) * POC: add chat session state frames Add configurable state frames for chat editor sessions, with idle, unvisited, running, and blocked treatments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 * Reset visited idle chat frames Only render session state frames for unvisited completions, running requests, and blocked sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 * Gate chat session state frames Replace the style enum with a false-by-default experimental boolean feature flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 * Clarify chat state indicator defaults Use a false-by-default host suppression option so the API semantics match the false-by-default feature flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 * Address chat state frame review Add focused state-machine coverage and non-color stroke cues for state frames. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 * Use positive chat state indicator enablement Make chat widget hosts opt in explicitly so both the host option and global feature flag default to false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 * Keep chat state indicator surface guards Explicitly reject inline and Quick Chat even when a host opts into session state indicators. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b136484-221e-4b5d-99db-948b6533fa53 --- .../chat/browser/chat.shared.contribution.ts | 6 ++ src/vs/workbench/contrib/chat/browser/chat.ts | 3 + .../contrib/chat/browser/widget/chatWidget.ts | 76 ++++++++++++++++++- .../chat/browser/widget/media/chat.css | 72 ++++++++++++++++++ .../browser/widgetHosts/editor/chatEditor.ts | 1 + .../contrib/chat/common/constants.ts | 1 + .../test/browser/widget/chatWidget.test.ts | 40 +++++++++- 7 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 5aa9aa64424d89..aed935b6aff2cd 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1004,6 +1004,12 @@ configurationRegistry.registerConfiguration({ default: true, markdownDescription: nls.localize('chat.progressBorder.enabled', "Show an animated gradient border around the chat input while the agent is working or thinking. Has no effect when reduced motion is enabled."), }, + [ChatConfiguration.SessionStateIndicatorEnabled]: { + type: 'boolean', + default: false, + description: nls.localize('chat.experimental.sessionStateIndicator.enabled', "Enable state indicators around chat editor sessions."), + tags: ['experimental'], + }, [ChatConfiguration.NotifyWindowOnResponseReceived]: { type: 'string', enum: ['off', 'windowNotFocused', 'always'], diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index f1578aa3b9ab07..60300b7ad8dd68 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -326,6 +326,9 @@ export interface IChatWidgetViewOptions { */ isSessionsWindow?: boolean; + /** Whether this host supports the experimental session state indicator. Defaults to false. */ + enableSessionStateIndicator?: boolean; + /** Enables the transcript Find widget (`Ctrl/Cmd+F`) for this chat widget. Off by default. */ enableFind?: boolean; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index abe5593812e653..b979b5aaea5968 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -302,6 +302,24 @@ export const chatPersistentContentVisibleClass = 'chat-persistent-content-visibl /** Carries {@link IChatWidgetViewOptions.persistentContentHeight} to `chat.css`. */ export const chatPersistentContentHeightVariable = '--vscode-chat-persistent-content-height'; +/** Computes the visual session state and whether its latest completion remains unvisited. */ +export function computeChatSessionStateIndicatorState(input: { + readonly requestNeedsInput: boolean; + readonly requestInProgress: boolean; + readonly containsFocus: boolean; + readonly requestWasActive: boolean; + readonly hasUnvisitedCompletion: boolean; +}) { + const state = input.requestNeedsInput ? 'needsInput' : input.requestInProgress ? 'inProgress' : 'idle'; + const requestActive = state !== 'idle'; + let hasUnvisitedCompletion = input.containsFocus ? false : input.hasUnvisitedCompletion; + if (!requestActive && input.requestWasActive) { + hasUnvisitedCompletion = !input.containsFocus; + } + + return { state, requestActive, hasUnvisitedCompletion }; +} + export class ChatWidget extends Disposable implements IChatWidget { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -468,6 +486,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly viewModelDisposables = this._register(new DisposableStore()); private _viewModel: ChatViewModel | undefined; + private _requestActiveForStateIndicator = false; + private _hasUnvisitedCompletion = false; private set viewModel(viewModel: ChatViewModel | undefined) { if (this._viewModel === viewModel) { @@ -478,6 +498,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewModelDisposables.clear(); this._viewModel = viewModel; + this._requestActiveForStateIndicator = false; + this._hasUnvisitedCompletion = false; if (viewModel) { this.viewModelDisposables.add(viewModel); this.logService.debug(`ChatWidget#setViewModel: have viewModel session=${viewModel.sessionResource.toString()} requests=${viewModel.model.getRequests().length}`); @@ -491,6 +513,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource }); + this.updateSessionStateIndicator(); } get viewModel() { @@ -672,6 +695,10 @@ export class ChatWidget extends Disposable implements IChatWidget { if (e.affectsConfiguration(ChatConfiguration.ProgressBorder)) { this.updateWorkingProgressBorder(); } + if (e.affectsConfiguration(ChatConfiguration.SessionStateIndicatorEnabled)) { + this.updateWorkingProgressBorder(); + this.updateSessionStateIndicator(); + } })); this._register(this.accessibilityService.onDidChangeReducedMotion(() => { @@ -947,13 +974,52 @@ export class ChatWidget extends Disposable implements IChatWidget { } const enabled = this.configurationService.getValue(ChatConfiguration.ProgressBorder) === true && !this.accessibilityService.isMotionReduced() - && !isInlineChat(this); + && !isInlineChat(this) + && !this.isSessionStateIndicatorEnabled(); const inProgress = !!this.viewModel?.model.requestInProgress.get(); const working = enabled && inProgress; inputContainer.classList.toggle('working', working); setChatInputStackInputWorking(inputContainer, working); } + private isSessionStateIndicatorEnabled(): boolean { + if (isInlineChat(this) || isQuickChat(this) || this.viewOptions.enableSessionStateIndicator !== true) { + return false; + } + + return this.configurationService.getValue(ChatConfiguration.SessionStateIndicatorEnabled) === true; + } + + /** Updates the whole-widget session state indicator. */ + private updateSessionStateIndicator(): void { + if (!this.container) { + return; + } + + const enabled = this.isSessionStateIndicatorEnabled(); + const modelNeedsInput = !!this.viewModel?.model.requestNeedsInput.get(); + const indicatorState = computeChatSessionStateIndicatorState({ + requestNeedsInput: modelNeedsInput, + requestInProgress: !!this.viewModel?.model.requestInProgress.get(), + containsFocus: dom.isAncestorOfActiveElement(this.container), + requestWasActive: this._requestActiveForStateIndicator, + hasUnvisitedCompletion: this._hasUnvisitedCompletion, + }); + this._requestActiveForStateIndicator = indicatorState.requestActive; + this._hasUnvisitedCompletion = indicatorState.hasUnvisitedCompletion; + + const needsInput = enabled && indicatorState.state === 'needsInput'; + const inProgress = enabled && indicatorState.state === 'inProgress'; + const idle = enabled && !needsInput && !inProgress; + const idleUnvisited = idle && this._hasUnvisitedCompletion; + + this.container.classList.toggle('chat-session-state-indicator', enabled); + this.container.classList.toggle('chat-state-needs-input', needsInput); + this.container.classList.toggle('chat-state-in-progress', inProgress); + this.container.classList.toggle('chat-state-idle', idle); + this.container.classList.toggle('chat-state-idle-unvisited', idleUnvisited); + } + get inputEditor(): ICodeEditor { return this.input.inputEditor; } @@ -1013,6 +1079,13 @@ export class ChatWidget extends Disposable implements IChatWidget { const renderInputToolbarBelowInput = this.viewOptions.renderInputToolbarBelowInput ?? false; this.container = dom.append(parent, $('.interactive-session')); + const focusTracker = this._register(dom.trackFocus(this.container)); + this._register(focusTracker.onDidFocus(() => { + if (this._hasUnvisitedCompletion) { + this.updateSessionStateIndicator(); + } + })); + this.updateSessionStateIndicator(); if (this.viewOptions.persistentContentHeight) { // The class floats the persistent content; the variable tells the // surfaces the list now extends behind how far to keep clear. @@ -2706,6 +2779,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.requestInProgress.set(this.viewModel.model.requestInProgress.get()); this.hasActiveRequest.set(this.viewModel.model.hasActiveRequest.get()); this.updateWorkingProgressBorder(); + this.updateSessionStateIndicator(); // Update the editor's placeholder text when it changes in the view model if (events?.some(e => e?.kind === 'changePlaceholder')) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index b7bcf2da95e27d..1cd7457f966358 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -26,6 +26,78 @@ --vscode-chat-font-size-body-xxl: 1.538em; } +@keyframes chat-session-working-glow { + 0%, + 100% { + box-shadow: 0 0 var(--vscode-spacing-size20) color-mix(in srgb, var(--vscode-charts-green) 35%, transparent); + } + + 50% { + box-shadow: 0 0 var(--vscode-spacing-size60) color-mix(in srgb, var(--vscode-charts-green) 80%, transparent); + } +} + +.monaco-workbench .interactive-session.chat-session-state-indicator:is(.chat-state-idle-unvisited, .chat-state-in-progress, .chat-state-needs-input)::before { + content: ''; + position: absolute; + inset: 0; + /* Paint above the Prompt Timeline sticky header (9) and rail (15). */ + z-index: 16; + pointer-events: none; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-medium); + transition: border-color 350ms ease, box-shadow 350ms ease; +} + +.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-idle-unvisited::before { + border-color: hsl(from var(--vscode-foreground) h 0% l / 0.7); + border-style: dotted; +} + +.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + border-color: var(--vscode-charts-green); + border-style: solid; + animation: chat-session-working-glow 1.4s ease-in-out infinite; +} + +.monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-needs-input::before { + border-color: var(--vscode-errorForeground); + border-style: dashed; + box-shadow: 0 0 var(--vscode-spacing-size80) color-mix(in srgb, var(--vscode-errorForeground) 60%, transparent); +} + +.monaco-workbench.hc-black .interactive-session.chat-session-state-indicator::before, +.monaco-workbench.hc-light .interactive-session.chat-session-state-indicator::before { + border-color: var(--vscode-contrastActiveBorder); + box-shadow: none; +} + +.monaco-workbench.hc-black .interactive-session.chat-session-state-indicator.chat-state-in-progress::before, +.monaco-workbench.hc-light .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + border: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + animation: none; +} + +.monaco-reduce-motion .interactive-session.chat-session-state-indicator::before, +.monaco-workbench.monaco-reduce-motion .interactive-session.chat-session-state-indicator::before { + transition: none; +} + +.monaco-reduce-motion .interactive-session.chat-session-state-indicator.chat-state-in-progress::before, +.monaco-workbench.monaco-reduce-motion .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + animation: none; +} + +@media (prefers-reduced-motion: reduce) { + .monaco-workbench .interactive-session.chat-session-state-indicator::before { + transition: none; + } + + .monaco-workbench .interactive-session.chat-session-state-indicator.chat-state-in-progress::before { + animation: none; + } +} + .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row > .monaco-tl-row > .monaco-tl-twistie, .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-tree-sticky-container .monaco-tree-sticky-row > .monaco-tl-row > .monaco-tl-twistie { /* Hide twisties from chat tree rows, but not from nested trees within a chat response */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index a1ad9b50783a77..d83dfdee8b4411 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -130,6 +130,7 @@ export class ChatEditor extends AbstractEditorWithViewState { ], [true, false]); }); + test('tracks unvisited completions and needs-input precedence', () => { + const active = computeChatSessionStateIndicatorState({ + requestNeedsInput: false, + requestInProgress: true, + containsFocus: false, + requestWasActive: false, + hasUnvisitedCompletion: false, + }); + const completed = computeChatSessionStateIndicatorState({ + requestNeedsInput: false, + requestInProgress: false, + containsFocus: false, + requestWasActive: active.requestActive, + hasUnvisitedCompletion: active.hasUnvisitedCompletion, + }); + const visited = computeChatSessionStateIndicatorState({ + requestNeedsInput: false, + requestInProgress: false, + containsFocus: true, + requestWasActive: completed.requestActive, + hasUnvisitedCompletion: completed.hasUnvisitedCompletion, + }); + const needsInput = computeChatSessionStateIndicatorState({ + requestNeedsInput: true, + requestInProgress: true, + containsFocus: true, + requestWasActive: visited.requestActive, + hasUnvisitedCompletion: visited.hasUnvisitedCompletion, + }); + + assert.deepStrictEqual({ active, completed, visited, needsInput }, { + active: { state: 'inProgress', requestActive: true, hasUnvisitedCompletion: false }, + completed: { state: 'idle', requestActive: false, hasUnvisitedCompletion: true }, + visited: { state: 'idle', requestActive: false, hasUnvisitedCompletion: false }, + needsInput: { state: 'needsInput', requestActive: true, hasUnvisitedCompletion: false }, + }); + }); + test('sticky request click survives synchronous template disposal during reveal', () => { const request = upcastPartial({ id: 'request', From c89cda63706360aa2fe45a0e5669c76fb20f29f8 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Tue, 1 Sep 2026 21:59:57 +0200 Subject: [PATCH 27/41] Improved visual clarity in multi-file diffs in Agents window (#333775) * Improved visual clarity in multi-file diffs in Agents window * Fix multi-diff bottom padding layout Avoid preserving a scroll anchor when replacing trailing padding and make the regression test deterministic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52046b61-1856-4a7d-9e41-6edd926637d2 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52046b61-1856-4a7d-9e41-6edd926637d2 --- .../multiDiffEditor/diffEditorItemTemplate.ts | 31 +++- .../multiDiffEditorViewModel.ts | 7 +- .../multiDiffEditor/multiDiffEditorWidget.ts | 7 + .../multiDiffEditorWidgetImpl.ts | 15 +- .../workbenchUIElementFactory.ts | 9 ++ .../widget/multiDiffEditorWidget.test.ts | 44 ++++++ src/vs/sessions/browser/media/workbench.css | 15 +- .../contrib/changes/browser/changesActions.ts | 19 ++- .../browser/media/multiFileDiffEditor.css | 136 ++++++++++++++---- .../changes/browser/sessionChangesEditor.ts | 8 ++ .../test/browser/agentsDiffEditor.fixture.ts | 5 + 11 files changed, 255 insertions(+), 41 deletions(-) diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index e9dae9d5e96f3c..44e683fe5dce4a 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, EventHelper, EventType, getWindow, h, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { addDisposableListener, EventHelper, EventType, getWindow, h, scheduleAtNextAnimationFrame, trackFocus } from '../../../../base/browser/dom.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; @@ -36,6 +36,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; public readonly maxScroll; @@ -70,9 +71,12 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate(this, undefined); this._collapsed = derived(this, reader => this._viewModel.read(reader)?.collapsed.read(reader)); this._editorContentHeight = observableValue(this, 500); + this._itemHorizontalInsets = this._workbenchUIElementFactory.diffEditorItemHorizontalInsets ?? { left: 9, right: 9 }; this.size = derived(this, reader => { - const collapsed = this._collapsed.read(reader); - return (collapsed ? 0 : this._editorContentHeight.read(reader)) + this._outerEditorHeight; + if (this._collapsed.read(reader)) { + return this._headerHeight; + } + return this._editorContentHeight.read(reader) + this._outerEditorHeight; }); this._modifiedContentWidth = observableValue(this, 0); this._modifiedWidth = observableValue(this, 0); @@ -129,15 +133,17 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this._viewModel.get()?.setActive(undefined); this._register(autorun(reader => { btn.element.className = ''; btn.icon = this._collapsed.read(reader) ? Codicon.chevronRight : Codicon.chevronDown; })); this._register(btn.onDidClick(() => { + activateItem(); this._viewModel.get()?.collapsed.set(!this._collapsed.get(), undefined); })); @@ -159,8 +165,17 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this._elements.root.classList.add('header-hovered'))); + this._register(addDisposableListener(this._elements.header, EventType.MOUSE_LEAVE, () => this._elements.root.classList.remove('header-hovered'))); + const headerFocus = this._register(trackFocus(this._elements.header)); + this._register(headerFocus.onDidFocus(() => { + this._elements.root.classList.add('header-focused'); + activateItem(); + })); + this._register(headerFocus.onDidBlur(() => this._elements.root.classList.remove('header-focused'))); this._register(addDisposableListener(this._elements.header, EventType.CLICK, (e) => { + activateItem(); // Don't toggle if clicking on actions or the collapse button itself (already handled) const target = e.target; if (!(target instanceof Element)) { @@ -174,6 +189,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { if (e.key === 'Enter' || e.key === ' ') { + activateItem(); const target = e.target; if (target instanceof Element && (target.closest('.actions') || target.closest('.collapse-button'))) { return; @@ -218,10 +234,12 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { const isActive = this._viewModel.read(reader)?.isActive.read(reader); this._elements.root.classList.toggle('active', isActive); + const isFirst = this._viewModel.read(reader)?.isFirst.read(reader); + this._elements.root.classList.toggle('first-diff-entry', isFirst); })); this._container.appendChild(this._elements.root); - this._outerEditorHeight = this._headerHeight; + this._outerEditorHeight = this._headerHeight + (this._workbenchUIElementFactory.diffEditorItemContentBottomPadding ?? 0); this._contextKeyService = this._register(_parentContextKeyService.createScoped(this._elements.actions)); const ctxAllUnchangedRegionsShown = EditorContextKeys.multiDiffEditorItemAllUnchangedRegionsShown.bindTo(this._contextKeyService); @@ -421,7 +439,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate { this.editor.layout({ - width: width - 2 * 8 - 2 * 1, + width: width - this._itemHorizontalInsets.left - this._itemHorizontalInsets.right, height: verticalRange.length - this._outerEditorHeight, }); }); @@ -447,6 +465,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this.items.read(reader).find(i => i.isFocused.read(reader))); public readonly activeDiffItem = derivedObservableWithWritableCache(this, - (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined + (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1 ? lastValue : undefined) ); public async waitForDiffOr1s(): Promise { @@ -137,6 +137,11 @@ export class DocumentDiffItemViewModel extends Disposable { public get modifiedUri(): URI | undefined { return this.documentDiffItem.modified?.uri; } public readonly isActive: IObservable = derived(this, reader => this._editorViewModel.activeDiffItem.read(reader) === this); + public readonly isFirst: IObservable = derived(this, reader => this._editorViewModel.items.read(reader)[0] === this); + + public setActive(tx: ITransaction | undefined): void { + this._editorViewModel.activeDiffItem.setCache(this, tx); + } private readonly _isFocusedSource = observableValue>(this, constObservable(false)); public readonly isFocused = derived(this, reader => this._isFocusedSource.read(reader).read(reader)); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 599cd1c936e09f..befafc3fcf06d4 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -27,6 +27,7 @@ export class MultiDiffEditorWidget extends Disposable { private readonly _dimension = observableValue(this, undefined); private readonly _viewModel = observableValue(this, undefined); private readonly _diffLayoutOptions = observableValue(this, undefined); + private readonly _paddingBottomPx = observableValue(this, 0); private readonly _widgetImpl = derived(this, (reader) => { readHotReloadableExport(DiffEditorItemTemplate, reader); @@ -38,6 +39,7 @@ export class MultiDiffEditorWidget extends Disposable { this._workbenchUIElementFactory, this._diffLayoutOptions, this._diffEditorOptions, + this._paddingBottomPx, )); }); @@ -112,6 +114,11 @@ export class MultiDiffEditorWidget extends Disposable { this.setRenderSideBySide(!(this._diffLayoutOptions.get()?.renderSideBySide ?? true)); } + /** Reserves empty space below the last diff entry. */ + public setPaddingBottom(px: number): void { + this._paddingBottomPx.set(px, undefined); + } + private readonly _activeControl = derived(this, (reader) => this._widgetImpl.read(reader).activeControl.read(reader)); public getActiveControl(): DiffEditorWidget | undefined { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 8c17e619bfd1e3..cdf7d096bc0b60 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -6,7 +6,7 @@ import { Dimension, h } from '../../../../base/browser/dom.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, IReader, ITransaction, autorun, autorunWithStore, derived, mapObservableArrayCached, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, IReader, ITransaction, autorun, autorunWithStore, constObservable, derived, mapObservableArrayCached, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ContextKeyValue, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -71,6 +71,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, private readonly _diffLayoutOptions: IObservable, private readonly _diffEditorOptions: IDiffEditorOptions | undefined, + private readonly _paddingBottomPx: IObservable, @IContextKeyService private readonly _parentContextKeyService: IContextKeyService, @IInstantiationService private readonly _parentInstantiationService: IInstantiationService, @ILogService logService: ILogService, @@ -81,6 +82,12 @@ export class MultiDiffEditorWidgetImpl extends Disposable { return { ...this._diffEditorOptions, ...this._diffLayoutOptions.read(reader) }; }); this._spaceBetweenPx = observableValue(this, 0); + const paddingBottomItem = this._paddingBottomPx.map(this, size => ({ + size: constObservable(size), + maxScroll: constObservable({ maxScroll: 0 }), + render() { }, + hide() { }, + })); let viewItemsInfo!: IObservable<{ items: readonly VirtualizedViewItem[]; getItem: (viewModel: DocumentDiffItemViewModel) => VirtualizedViewItem }>; let viewItems!: IObservable; @@ -93,7 +100,9 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const manager = this._register(new VirtualizedItemManager(sourceItems, context, { getId: item => item, getTemplateId: () => 'diffEditor', - getUnboundSize: item => derived(item, reader => item.collapsed.read(reader) ? 40 : item.lastTemplateData.read(reader).expandedContentHeight), + getUnboundSize: item => derived(item, reader => item.collapsed.read(reader) + ? this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40 + : item.lastTemplateData.read(reader).expandedContentHeight), createTemplate: () => this._instantiationService.createInstance( DiffEditorItemTemplate, context.contentDomNode, @@ -143,7 +152,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { return { items, getItem: d => map.get(d)! }; }); viewItems = viewItemsInfo.map(this, items => items.items); - return viewItems; + return derived(this, reader => [...viewItems.read(reader), paddingBottomItem.read(reader)]); }, )); this._viewItemsInfo = viewItemsInfo; diff --git a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts index a69dde587e7ca8..f853df6d75ad9f 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts @@ -28,6 +28,15 @@ export const enum MultiDiffEditorItemLabelKind { export interface IWorkbenchUIElementFactory { createResourceLabel?(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel; + /** Horizontal insets reserved around each embedded diff editor. */ + readonly diffEditorItemHorizontalInsets?: Readonly<{ left: number; right: number }>; + + /** Height of each entry's file header, in px. Defaults to 40. */ + readonly diffEditorItemHeaderHeight?: number; + + /** Padding reserved below each embedded diff editor, in px. Defaults to 0. */ + readonly diffEditorItemContentBottomPadding?: number; + /** * When true, the entire header area is clickable to toggle collapse/expand * and receives keyboard activation (Enter/Space) and ARIA button semantics. diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index 2479bbf4be99c0..7150e1bc0e3cef 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -38,6 +38,50 @@ suite('MultiDiffEditorWidget', () => { sinon.restore(); }); + test('models bottom padding as trailing scroll content', () => { + const services = new ServiceCollection(); + services.set(IAccessibilitySignalService, new class extends mock() { }()); + services.set(IActionViewItemService, new NullActionViewItemService()); + services.set(IEditorProgressService, new class extends mock() { }()); + services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); + services.set(IStorageService, disposables.add(new InMemoryStorageService())); + services.set(IMenuService, new class extends mock() { + override createMenu(): IMenu { + return new class extends mock() { + override readonly onDidChange = Event.None; + override getActions() { return []; } + override dispose(): void { } + }(); + } + }()); + const instantiationService = createCodeEditorServices(disposables, services); + const container = document.createElement('div'); + const widget = instantiationService.createInstance( + MultiDiffEditorWidget, + container, + {} satisfies IWorkbenchUIElementFactory, + undefined, + ); + widget.layout(new Dimension(800, 200)); + const initialState = widget.getLayoutDebugState().get(); + widget.setPaddingBottom(24); + + try { + const state = widget.getLayoutDebugState().get(); + assert.deepStrictEqual({ + logicalScrollHeightDelta: state.layout.logicalScrollHeight - initialState.layout.logicalScrollHeight, + scrollHeightDelta: state.scrollDimensions.scrollHeight - initialState.scrollDimensions.scrollHeight, + diffItems: state.items.length, + }, { + logicalScrollHeightDelta: 24, + scrollHeightDelta: 24, + diffItems: 0, + }); + } finally { + widget.dispose(); + } + }); + test('applies document and responsive layout options before attaching the diff model', async () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index 7af1ca383140fa..9a562e6a26ebb8 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -147,7 +147,7 @@ .agent-sessions-workbench:not(.dock-detail-panel) .part.auxiliarybar { margin: 0 0 0 0; - padding-left: 5px; + padding-left: var(--vscode-spacing-size60); background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-top-left-radius: 0; @@ -291,8 +291,19 @@ } .agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title { - border-bottom: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); box-sizing: border-box; + position: relative; +} + +.agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title::after { + content: ''; + position: absolute; + right: var(--vscode-spacing-size100); + bottom: 0; + left: var(--vscode-spacing-size100); + height: var(--vscode-strokeThickness); + background: var(--vscode-agentsPanel-border); + pointer-events: none; } .agent-sessions-workbench.dock-detail-panel .part.auxiliarybar > .content .pane-body, diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index a369dc3fa420be..d297c207558671 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -10,6 +10,7 @@ import { structuralEquals } from '../../../../base/common/equals.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; @@ -149,6 +150,20 @@ function getChangesDiffEditor(pane: IEditorPane | undefined, resource: URI): Dif return codeEditor?.diffEditor instanceof DiffEditorWidget ? codeEditor.diffEditor : undefined; } +function getExpandedChangesDiffEditor(pane: IEditorPane | undefined, resource: URI): DiffEditorWidget | undefined { + if (pane instanceof SessionChangesEditor) { + pane.expand(resource); + } else if (pane instanceof MultiDiffEditor) { + const viewModel = pane.viewModel; + const item = viewModel?.items.read(undefined) + .find(item => isEqual(item.modifiedUri, resource) || isEqual(item.originalUri, resource)); + if (viewModel && item) { + viewModel.expand(item); + } + } + return getChangesDiffEditor(pane, resource); +} + /** * Reveals all hidden unchanged regions for the file shown in a diff row of the * Agents window's Changes editor, showing the whole file at once (a per-file @@ -181,7 +196,7 @@ class ExpandFullFileAction extends Action2 { return; } - getChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.showAllUnchangedRegions(); + getExpandedChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.showAllUnchangedRegions(); } } registerAction2(ExpandFullFileAction); @@ -222,7 +237,7 @@ class CollapseUnchangedRegionsAction extends Action2 { return; } - getChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.collapseAllUnchangedRegions(); + getExpandedChangesDiffEditor(accessor.get(IEditorService).activeEditorPane, resource)?.collapseAllUnchangedRegions(); } } registerAction2(CollapseUnchangedRegionsAction); diff --git a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css b/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css index 91515889bd4d56..e546345b978c83 100644 --- a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css +++ b/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css @@ -7,37 +7,66 @@ * used by the Changes view / editor). Kept out of the shared workbench style so * these overrides live next to the Changes contribution that owns them. */ -/* The per-file diff rows intentionally render flush with the panel (the old - * `padding: 0 8px` inset was removed as part of the spacing rework); only - * `box-sizing` is kept here. */ -.agent-sessions-workbench .part.editor .multiDiffEntry { - box-sizing: border-box; +.agent-sessions-workbench .part.editor .multiDiffEntry::after { + content: ''; + position: absolute; + right: var(--vscode-spacing-size100); + bottom: 0; + left: var(--vscode-spacing-size100); + height: var(--vscode-strokeThickness); + background: var(--vscode-panel-border); + z-index: 1001; + pointer-events: none; } .agent-sessions-workbench .part.editor .multiDiffEntry .header { cursor: pointer; - background: transparent; + background: var(--vscode-multiDiffEditor-background); position: relative; } -.agent-sessions-workbench .part.editor .multiDiffEntry .header::before { +.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered, +.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active { + overflow: visible; + z-index: 1002; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::after, +.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::after, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::after { + right: 0; + left: 0; + z-index: 1004; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::before, +.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::before, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::before { content: ''; position: absolute; - inset: 0; - background: var(--vscode-agentsPanel-background); - z-index: -1; + top: calc(-1 * var(--vscode-strokeThickness)); + right: 0; + left: 0; + height: var(--vscode-strokeThickness); + background: var(--vscode-panel-border); + z-index: 1004; pointer-events: none; } -/* Include the multi-diff owner to outrank the equally specific core - * `.monaco-component.multiDiffEditor .multiDiffEntry .header .header-content` - * rule for the Agents header margin, padding, border, and background. */ -.agent-sessions-workbench .part.editor .multiDiffEditor .multiDiffEntry .header-content { - border-radius: var(--vscode-cornerRadius-medium); +.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-hovered::before, +.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-focused::before, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.first-diff-entry.active::before { + top: 0; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header .header-content { + height: var(--vscode-spacing-size320); + box-sizing: border-box; border: none; - background: var(--vscode-sideBarSectionHeader-background); + background: transparent; margin: 0; - padding-left: var(--vscode-spacing-sizeNone); + padding: var(--vscode-spacing-sizeNone); align-items: center; } @@ -45,9 +74,28 @@ border-bottom: none; } -/* The same owner keeps the Agents body font size from tying the core - * `.header-content .file-path .title` 14px rule. */ -.agent-sessions-workbench .part.editor .multiDiffEditor .multiDiffEntry .header-content .file-path .title { +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header .header-content { + background: var(--vscode-list-inactiveSelectionBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content { + background: var(--vscode-list-hoverBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header:active .header-content { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:hover .header-content, +.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:active .header-content { + background: var(--vscode-list-inactiveSelectionBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header[aria-expanded="false"] + .editorParent { + border: none; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title { font-size: var(--vscode-fontSize-body1); line-height: 22px; } @@ -85,22 +133,56 @@ gap: 4px; } -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus, -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible { +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) { + visibility: hidden; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry:hover .header-content .actions-container .action-item:not(.changeset-review-action), +.agent-sessions-workbench .part.editor .multiDiffEntry:focus-within .header-content .actions-container .action-item:not(.changeset-review-action) { + visibility: visible; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus { outline: none; } -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus .header-content, .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible .header-content { - box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); } .agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a { - border-radius: var(--vscode-cornerRadius-small); + display: flex; + align-items: center; + justify-content: center; + padding: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-medium); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) .action-label { + padding: var(--vscode-spacing-size40); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:active { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus:not(:focus-visible) { + outline: none; } .agent-sessions-workbench .part.editor .multiDiffEntry .editorParent { - border-bottom: none; + border: none; + overflow: hidden; } .agent-sessions-workbench .part.editor .diff-hidden-lines .center { @@ -113,7 +195,7 @@ * line-number gutter width (which pushes it to the right). */ .agent-sessions-workbench .part.editor .diff-hidden-lines .center > div:first-child { justify-content: flex-start !important; - padding-left: 5px; + padding-left: var(--vscode-spacing-size60); box-sizing: border-box; } diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 214f63182c7b6c..6e924a37405f41 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -67,9 +67,16 @@ const CHANGES_DIFF_EDITOR_OPTIONS: IDiffEditorOptions = { lineNumbersMinChars: 3, }; +const CHANGES_LIST_BOTTOM_PADDING_PX = 24; +const CHANGES_ENTRY_HEADER_HEIGHT_PX = 32; +const CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX = 8; + class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { readonly headerClickToCollapse = true; + readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; + readonly diffEditorItemHeaderHeight = CHANGES_ENTRY_HEADER_HEIGHT_PX; + readonly diffEditorItemContentBottomPadding = CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX; constructor( private readonly changesObs: IObservable, @@ -261,6 +268,7 @@ export class SessionChangesEditor extends AbstractEditorWithViewState { this.widget?.setRenderSideBySide(this.diffEditorOptionsService.renderSideBySide.read(reader), { useInlineViewWhenSpaceIsLimited: true }); })); diff --git a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts index 6b7dc2250addcd..de137ae57ed0ad 100644 --- a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts +++ b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts @@ -101,6 +101,11 @@ class FixtureAgentFeedbackMenuService implements IMenuService { class AgentsDiffUIElementFactory implements IWorkbenchUIElementFactory { + readonly headerClickToCollapse = true; + readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; + readonly diffEditorItemHeaderHeight = 32; + readonly diffEditorItemContentBottomPadding = 8; + constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } From 6abb03943d308026cc54f588cb873d165bd7d6ae Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 1 Sep 2026 16:30:22 -0400 Subject: [PATCH 28/41] sessions: show workspace before harness picker (#333844) * sessions: show workspace before harness picker Fixes #333807 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: place context picker after harness Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: test picker order after quick chat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: update new session picker screenshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatWidget.ts | 24 +++---- .../chat/test/browser/newChatWidget.test.ts | 67 ++++++++++++++++--- .../blocks-ci-screenshots.md | 16 ++--- 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index da7b43eff538cb..ca4484eb21ec83 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -466,12 +466,7 @@ export class NewChatWidget extends Disposable { if (!target) { return; } - this._newChatInput.sessionTypePicker.render(target, { - className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', - }); - if (!isQuickChat && target.lastElementChild) { - target.insertBefore(target.lastElementChild, target.firstElementChild); - } + this._renderSessionTypePicker(target, isQuickChat); })); } @@ -715,12 +710,7 @@ export class NewChatWidget extends Disposable { workspaceTrigger, gitHubContextTrigger, ]); - this._newChatInput.sessionTypePicker.render(row, { - className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', - }); - if (row.lastElementChild) { - row.insertBefore(row.lastElementChild, row.firstElementChild); - } + this._renderSessionTypePicker(row, false); this._workspacePickerRow = row; return toDisposable(() => { if (this._workspacePickerRow === row) { @@ -729,6 +719,16 @@ export class NewChatWidget extends Disposable { }); } + private _renderSessionTypePicker(container: HTMLElement, isQuickChat: boolean): void { + this._newChatInput.sessionTypePicker.render(container, { + className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', + }); + const sessionTypePicker = container.lastElementChild; + if (!isQuickChat && sessionTypePicker?.previousElementSibling) { + container.insertBefore(sessionTypePicker, sessionTypePicker.previousElementSibling); + } + } + private _renderEmptyState(container: HTMLElement): IDisposable { this._workspacePickerVisibleKey.set(false); const emptyState = this.instantiationService.createInstance(NoAgentHostEmptyState); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index e9c2adb165af85..bb10cb56d2acb1 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -8,7 +8,7 @@ import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -114,20 +114,25 @@ interface ISendHarness { readonly logService: { error(message: string, ...args: unknown[]): void }; } -interface IRenderWorkspacePickerHarness { - readonly _workspacePickerVisibleKey: { set(value: boolean): void }; - readonly _workspacePicker: { - renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean }[]): HTMLElement; - }; +interface IRenderSessionTypePickerHarness { readonly _newChatInput: { readonly sessionTypePicker: { render(container: HTMLElement, options?: { className?: string }): void; }; }; +} + +interface IRenderWorkspacePickerHarness extends IRenderSessionTypePickerHarness { + readonly _workspacePickerVisibleKey: { set(value: boolean): void }; + readonly _workspacePicker: { + renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean }[]): HTMLElement; + }; + _renderSessionTypePicker(container: HTMLElement, isQuickChat: boolean): void; _workspacePickerRow: HTMLElement | undefined; } const renderWorkspacePicker = Reflect.get(NewChatWidget.prototype, '_renderWorkspacePicker') as (this: IRenderWorkspacePickerHarness, container: HTMLElement) => IDisposable; +const renderSessionTypePicker = Reflect.get(NewChatWidget.prototype, '_renderSessionTypePicker') as (this: IRenderSessionTypePickerHarness, container: HTMLElement, isQuickChat: boolean) => void; function createHarness( pendingPreferredUpgrade: MutableDisposable, @@ -158,7 +163,7 @@ function createHarness( suite('NewChatWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('workspace row hosts a multiple-harness picker first', () => { + test('workspace row hosts the workspace picker before the multiple-harness and context pickers', () => { const container = document.createElement('div'); const harnessLabels = ['Copilot', 'Claude']; const workspaceTriggers: { readonly tooltip: string | undefined; readonly icon: string | undefined; readonly attachesContext: boolean | undefined }[] = []; @@ -190,6 +195,7 @@ suite('NewChatWidget', () => { }, }, }, + _renderSessionTypePicker: (target, isQuickChat) => renderSessionTypePicker.call(harness, target, isQuickChat), _workspacePickerRow: undefined, }; @@ -201,8 +207,8 @@ suite('NewChatWidget', () => { className: element.className, })), [ - { label: 'Copilot', className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot' }, { label: 'Workspace', className: '' }, + { label: 'Copilot', className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot' }, { label: 'Issue/PR', className: '' }, ], ); @@ -212,6 +218,51 @@ suite('NewChatWidget', () => { ]); }); + test('restores workspace, harness, context DOM and tab order after quick chat', () => { + const workspaceRow = document.createElement('div'); + const quickChatHeader = document.createElement('div'); + for (const label of ['Workspace', 'Issue/PR']) { + const item = document.createElement('a'); + item.tabIndex = 0; + item.textContent = label; + workspaceRow.appendChild(item); + } + let renderedPicker: HTMLElement | undefined; + const harness: IRenderSessionTypePickerHarness = { + _newChatInput: { + sessionTypePicker: { + render: (target, options) => { + renderedPicker?.remove(); + const item = document.createElement('a'); + item.tabIndex = 0; + item.className = options?.className ?? ''; + item.textContent = 'Copilot'; + target.appendChild(item); + renderedPicker = item; + }, + }, + }, + }; + + const isQuickChat = observableValue('isQuickChat', false); + disposables.add(autorun(reader => { + const value = isQuickChat.read(reader); + renderSessionTypePicker.call(harness, value ? quickChatHeader : workspaceRow, value); + })); + isQuickChat.set(true, undefined); + isQuickChat.set(false, undefined); + + assert.deepStrictEqual({ + domOrder: Array.from(workspaceRow.children, element => element.textContent), + tabOrder: Array.from(workspaceRow.querySelectorAll('[tabindex="0"]'), element => element.textContent), + quickChatHeader: Array.from(quickChatHeader.children, element => element.textContent), + }, { + domOrder: ['Workspace', 'Copilot', 'Issue/PR'], + tabOrder: ['Workspace', 'Copilot', 'Issue/PR'], + quickChatHeader: [], + }); + }); + test('replays a provider change that arrives while creating the draft', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 402235158e8e90..b3f6d210a38003 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -187,28 +187,28 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f448cad0bf3f94c930bd1dbd3cc76f41100f375adaa02c683f8dbdd53caf8b3e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/62ff8ab266d68ed5dc3f46d122cc05f67c1580222b0eeb3c83d7baea6c9e0937) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3283252df24d7dc46007ec8090d8db038b8b238be9793e1a76ce495862433337) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/41ea93d7262689955020b1eced4f7c7b8b423bd592bb29fba739d1b982b67a17) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5e62a510e536d64b4d76dc940a3ad0b744caa7c3a58111492be9f20ea8db801) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7228d732b817fa0201537ab73726c055a32e82ec4744abf94122e1b5b9f6054f) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/55683f61601d660f2f94c5c7a24179d782a0776464fa2680c5e00fa7b6fb2ee8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a4875725e56c9aea1ebd8aebdc8106fe955b9d89b3dc7115cadf290792590f0a) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8c86c66d82f1ef4e19e918463bfb5827167a22e238d81f66d1e48870862fb229) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/34408877e1ac8af8668377237c835da520bfc4a1301ebd55f6dab32084f88042) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a96db096792e9d7f75306eaa04c7e89e26a39870871b8480cc39fb5cf2cfe71) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ef4a895e36499772c251ee00d72d765d084b5082430e430c99398b7a12761c7a) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e9cbba25dc68f5f66bf85428b133461f2d1ebc236b1d6f82d08ae05aab9c33c7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/eaf71f4c733c6a7dc5c7bf9c48578243c6eb271769bb40593e8df5b977eb6111) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c51cbace6b7e770064231bb3aac07f91ad41e4fcf4d5bd6aedfbdb00c775fa45) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/79d4608ac44ba3bf43cb4555a8e02fca4165866a2fb6b518d3c3994b9228e37c) #### sessions/sessionsList/SessionsList_NestedChatHierarchyGuides/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/ae6689aace7015963b6fc3812c77019921e63523f97f630afde63579729077f2) From 33913cb25bd6c0a9fdd6780cabc4b4ad5005aae6 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 1 Sep 2026 16:30:27 -0400 Subject: [PATCH 29/41] sessions: Sync GitHub context from new session input (#333847) * sessions: sync GitHub context from new session input Create and remove issue and pull request context attachments as matching URLs are edited in the new-session composer. Preserve explicitly selected context when it matches an input-derived attachment.\n\nFixes #333845\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: validate input context metadata Route GitHub input-context metadata through a common validating reader to satisfy metadata hygiene.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatInput.ts | 62 +++++++++- .../contrib/chat/common/newChatContextIds.ts | 14 +++ .../chat/test/browser/newChatInput.test.ts | 106 ++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index a8d058a7ed0b56..aeb559d626f5ca 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -127,6 +127,7 @@ import { animatePromptTyping, IPromptTypingAnimation } from './promptTypingAnima import { PromptTemplatePlaceholderController } from './promptTemplatePlaceholder.js'; import { INewSessionComposer, INewSessionPromptOptionsController, NEW_SESSION_PROMPT_TYPING_DURATION_MS, NewSessionPromptOptionsState, NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { NewSessionPromptOptionsWidget } from './newSessionPromptOptions.js'; +import { isInputGitHubContext, toInputGitHubContextMetadata } from '../common/newChatContextIds.js'; const OPEN_OTEL_SETTINGS_COMMAND = 'github.copilot.chat.otel.openSettings'; @@ -134,6 +135,7 @@ const OTEL_STATUS_COMMAND = 'github.copilot.chat.otel.statusActive'; const OTEL_STATUS_ENTRY_ID = 'copilot.otelStatus'; const OTEL_DOCS_URL = 'https://code.visualstudio.com/docs/agents/guides/monitoring-agents'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; +const GITHUB_ISSUE_OR_PULL_REQUEST_URL_PATTERN = /\bhttps?:\/\/(?:www\.)?github\.com\/(?[\w.-]+)\/(?[\w.-]+)\/(?issues|pull)\/(?\d+)\b/gi; const MIN_EDITOR_HEIGHT = 50; const MAX_EDITOR_HEIGHT = 200; const NEW_CHAT_INPUT_FONT_FAMILY = 'system-ui, -apple-system, sans-serif'; @@ -212,6 +214,33 @@ export function hasSendableNewChatContent(query: string, attachments: readonly I return !!query.trim() || attachments.some(isExplicitFileOrImageVariableEntry) || hasAdditionalSendContent; } +function getInputGitHubContextAttachments(input: string): readonly IChatRequestVariableEntry[] { + const attachments: IChatRequestVariableEntry[] = []; + const ids = new Set(); + for (const match of input.matchAll(GITHUB_ISSUE_OR_PULL_REQUEST_URL_PATTERN)) { + const groups = match.groups; + const number = Number(groups?.['number']); + if (!groups || !Number.isSafeInteger(number) || number <= 0) { + continue; + } + const owner = groups['owner']; + const repo = groups['repo']; + const kind = groups['kind'].toLowerCase(); + const uri = `https://github.com/${owner}/${repo}/${kind}/${number}`; + const id = `github-context:${uri}`; + if (ids.has(id)) { + continue; + } + ids.add(id); + attachments.push(toPasteVariableEntry(`${owner}/${repo}#${number}`, `GitHub context: ${uri}`, { + id, + icon: kind === 'issues' ? Codicon.issues : Codicon.gitPullRequest, + _meta: toInputGitHubContextMetadata(), + })); + } + return attachments; +} + class NewChatInputStatusActionViewItem extends MenuEntryActionViewItem { private readonly hoverContentDisposables = this._register(new MutableDisposable()); @@ -996,6 +1025,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation ))); this._register(this._editor.onDidChangeModelContent(() => { + this._syncInputGitHubContext(); this._updateDraftState(); this._updateSendButtonState(); this._updateEditorFontFamily(); @@ -1397,6 +1427,25 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this.saveState(); } + private _syncInputGitHubContext(): void { + const inputAttachments = getInputGitHubContextAttachments(this._editor?.getValue() ?? ''); + const inputAttachmentIds = new Set(inputAttachments.map(attachment => attachment.id)); + const attachments = this._contextAttachments.attachments.filter(attachment => + !isInputGitHubContext(attachment) || inputAttachmentIds.has(attachment.id) + ); + const attachmentIds = new Set(attachments.map(attachment => attachment.id)); + for (const attachment of inputAttachments) { + if (!attachmentIds.has(attachment.id)) { + attachments.push(attachment); + attachmentIds.add(attachment.id); + } + } + if (attachments.length !== this._contextAttachments.attachments.length + || attachments.some((attachment, index) => attachment !== this._contextAttachments.attachments[index])) { + this._contextAttachments.setAttachments(attachments); + } + } + private _toHistoryEntry(draft: IDraftState): IChatModelInputState { return { ...draft, @@ -1534,6 +1583,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation if (draft.attachments?.length) { this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport)); } + this._syncInputGitHubContext(); } this._updateSendButtonState(); } @@ -1768,10 +1818,18 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } attachTextContext(name: string, content: string, icon: ThemeIcon, id = `context:${content}`): void { - this._contextAttachments.addAttachments(toPasteVariableEntry(name, content, { + const attachment = toPasteVariableEntry(name, content, { id, icon, - })); + }); + const index = this._contextAttachments.attachments.findIndex(entry => entry.id === id); + if (index < 0) { + this._contextAttachments.addAttachments(attachment); + } else { + const attachments = [...this._contextAttachments.attachments]; + attachments[index] = attachment; + this._contextAttachments.setAttachments(attachments); + } } addAttachments(...attachments: IChatRequestVariableEntry[]): void { diff --git a/src/vs/sessions/contrib/chat/common/newChatContextIds.ts b/src/vs/sessions/contrib/chat/common/newChatContextIds.ts index 5174ec7e7b8995..381ca4d51cebf2 100644 --- a/src/vs/sessions/contrib/chat/common/newChatContextIds.ts +++ b/src/vs/sessions/contrib/chat/common/newChatContextIds.ts @@ -7,6 +7,11 @@ import { URI } from '../../../../base/common/uri.js'; export const ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX = 'sessions-additional-folder:'; export const ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX = 'sessions-additional-repository:'; +const INPUT_GITHUB_CONTEXT_METADATA_KEY = 'sessionsInputGitHubContext'; + +interface IHasInputGitHubContextMetadata { + readonly _meta?: Record; +} export function getAdditionalFolderContextId(uri: URI): string { return `${ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX}${uri.toString()}`; @@ -19,3 +24,12 @@ export function getAdditionalRepositoryContextId(uri: URI): string { export function isAdditionalWorkspaceContextId(id: string): boolean { return id.startsWith(ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX) || id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX); } + +export function isInputGitHubContext(source: IHasInputGitHubContextMetadata): boolean { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the input GitHub context slot. + return source._meta?.[INPUT_GITHUB_CONTEXT_METADATA_KEY] === true; +} + +export function toInputGitHubContextMetadata(): Record { + return { [INPUT_GITHUB_CONTEXT_METADATA_KEY]: true }; +} diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index a0e8a424403bfb..6743a1be46e45e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -43,6 +43,8 @@ const saveState = Reflect.get(NewChatInputWidget.prototype, 'saveState') as (thi const clearDraftState = Reflect.get(NewChatInputWidget.prototype, '_clearDraftState') as (this: IDraftStateHarness) => void; const updateDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateDraftState') as (this: IUpdateDraftStateHarness) => void; const updateAndSaveDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateAndSaveDraftState') as (this: IUpdateAndSaveDraftStateHarness) => void; +const syncInputGitHubContext = Reflect.get(NewChatInputWidget.prototype, '_syncInputGitHubContext') as (this: ISyncInputGitHubContextHarness) => void; +const attachTextContext = Reflect.get(NewChatInputWidget.prototype, 'attachTextContext') as (this: IAttachTextContextHarness, name: string, content: string, icon: ThemeIcon, id: string) => void; const updateSendButtonState = Reflect.get(NewChatInputWidget.prototype, '_updateSendButtonState') as (this: IUpdateSendButtonStateHarness) => void; const setInputEditorFocused = Reflect.get(NewChatInputWidget.prototype, '_setInputEditorFocused') as (container: HTMLElement, focused: boolean) => void; const updateAttachmentRendering = Reflect.get(NewChatContextAttachments.prototype, '_updateRendering') as (this: IAttachmentRenderingHarness) => void; @@ -63,6 +65,7 @@ interface IRestoreStateHarness { readonly _contextAttachments: { setAttachments(entries: readonly IChatRequestVariableEntry[]): void; }; + _syncInputGitHubContext(): void; _updateSendButtonState(): void; } @@ -81,6 +84,24 @@ interface IUpdateAndSaveDraftStateHarness extends IUpdateDraftStateHarness { saveState(): void; } +interface ISyncInputGitHubContextHarness { + readonly _editor: { + getValue(): string; + }; + readonly _contextAttachments: { + attachments: readonly IChatRequestVariableEntry[]; + setAttachments(entries: readonly IChatRequestVariableEntry[]): void; + }; +} + +interface IAttachTextContextHarness { + readonly _contextAttachments: { + attachments: readonly IChatRequestVariableEntry[]; + addAttachments(...entries: IChatRequestVariableEntry[]): void; + setAttachments(entries: readonly IChatRequestVariableEntry[]): void; + }; +} + interface IUpdateSendButtonStateHarness { readonly _sendButton: { enabled: boolean } | undefined; readonly _sending: boolean; @@ -278,6 +299,7 @@ suite('NewChatInputWidget', () => { _getDraftState: () => draft, _editor: { getModel: () => ({ setValue: value => restored.inputText = value }) }, _contextAttachments: { setAttachments: entries => restored.attachments = entries }, + _syncInputGitHubContext: () => { }, _updateSendButtonState: () => { }, }); @@ -371,6 +393,7 @@ suite('NewChatInputWidget', () => { }, options: {}, _canSendRequest: { get: () => true }, + _syncInputGitHubContext: () => { }, _updateSendButtonState() { updateSendButtonState.call(this); }, @@ -381,6 +404,89 @@ suite('NewChatInputWidget', () => { assert.strictEqual(sendButton.enabled, true); }); + test('synchronizes GitHub context attachments with issue and pull request links in the input', () => { + let input = 'Fix https://github.com/microsoft/vscode/issues/333845 and review https://www.github.com/microsoft/vscode/pull/333575#discussion.'; + const manualAttachment = toPasteVariableEntry('Manually attached', 'Manual context', { + id: 'github-context:https://github.com/microsoft/vscode/issues/1', + }); + let attachments: readonly IChatRequestVariableEntry[] = [manualAttachment]; + const harness: ISyncInputGitHubContextHarness = { + _editor: { getValue: () => input }, + _contextAttachments: { + get attachments() { return attachments; }, + setAttachments: entries => attachments = entries, + }, + }; + const snapshot = () => attachments.map(attachment => ({ + id: attachment.id, + name: attachment.name, + icon: ThemeIcon.isThemeIcon(attachment.icon) ? attachment.icon.id : undefined, + })); + + syncInputGitHubContext.call(harness); + const withLinks = snapshot(); + input = 'Review https://github.com/microsoft/vscode/pull/333575.'; + syncInputGitHubContext.call(harness); + const afterRemovingIssueLink = snapshot(); + input = ''; + syncInputGitHubContext.call(harness); + + assert.deepStrictEqual({ + withLinks, + afterRemovingIssueLink, + afterRemovingAllLinks: snapshot(), + }, { + withLinks: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + { id: 'github-context:https://github.com/microsoft/vscode/issues/333845', name: 'microsoft/vscode#333845', icon: Codicon.issues.id }, + { id: 'github-context:https://github.com/microsoft/vscode/pull/333575', name: 'microsoft/vscode#333575', icon: Codicon.gitPullRequest.id }, + ], + afterRemovingIssueLink: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + { id: 'github-context:https://github.com/microsoft/vscode/pull/333575', name: 'microsoft/vscode#333575', icon: Codicon.gitPullRequest.id }, + ], + afterRemovingAllLinks: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + ], + }); + }); + + test('preserves pasted GitHub context after the same target is explicitly attached', () => { + const uri = 'https://github.com/microsoft/vscode/issues/333845'; + let input = `Fix ${uri}`; + let attachments: readonly IChatRequestVariableEntry[] = []; + const contextAttachments = { + get attachments() { return attachments; }, + addAttachments: (...entries: IChatRequestVariableEntry[]) => attachments = [...attachments, ...entries], + setAttachments: (entries: readonly IChatRequestVariableEntry[]) => attachments = entries, + }; + const syncHarness: ISyncInputGitHubContextHarness = { + _editor: { getValue: () => input }, + _contextAttachments: contextAttachments, + }; + + syncInputGitHubContext.call(syncHarness); + attachTextContext.call( + { _contextAttachments: contextAttachments }, + 'microsoft/vscode#333845', + `GitHub context: ${uri}`, + Codicon.issues, + `github-context:${uri}`, + ); + input = ''; + syncInputGitHubContext.call(syncHarness); + + assert.deepStrictEqual(attachments.map(attachment => ({ + id: attachment.id, + name: attachment.name, + icon: ThemeIcon.isThemeIcon(attachment.icon) ? attachment.icon.id : undefined, + })), [{ + id: `github-context:${uri}`, + name: 'microsoft/vscode#333845', + icon: Codicon.issues.id, + }]); + }); + test('renders GitHub context pills as openable with a keyboard-reachable remove button', async () => { const container = document.createElement('div'); const entry = toPasteVariableEntry('microsoft/vscode#332825', 'GitHub context: https://github.com/microsoft/vscode/pull/332825', { From 9e62c0b7c45ccccb04b568f60e4593d274005e96 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 1 Sep 2026 16:31:42 -0400 Subject: [PATCH 30/41] dictation: Cap recording and finalization duration (#333846) Stop active dictation after 20 minutes and bound NeMo finalization to eight seconds, preserving the streamed transcript when finalization stalls. Fixes #333832. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../speechToText/chatSpeechToTextService.ts | 30 ++++++++++- .../browser/speechToText/dictationSession.ts | 19 ++++++- .../browser/chatSpeechToTextService.test.ts | 51 +++++++++++++++++++ .../test/browser/dictationSession.test.ts | 24 +++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index 2c46d3b1187a6d..eb07cec597ce85 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -16,7 +16,7 @@ import { IContextKey, IContextKeyService } from '../../../../../platform/context import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { IProgress, IProgressService, IProgressStep, Progress, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; -import { DeferredPromise, raceCancellation } from '../../../../../base/common/async.js'; +import { DeferredPromise, raceCancellation, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -159,6 +159,8 @@ export function isDictationEntitled(entitlement: ChatEntitlement, isInternal: bo const MAI_CONNECT_TIMEOUT_MS = 8000; /** How long to wait after `ptt_end` for the backend's final transcript before returning what we have. */ const MAI_FINAL_TIMEOUT_MS = 4000; +/** How long to wait for the on-device backend to finish before returning its streamed transcript. */ +const NEMO_FINAL_TIMEOUT_MS = 8000; /** How long to wait for the backend to acknowledge the opened session before streaming audio anyway. */ const MAI_SESSION_INIT_TIMEOUT_MS = 4000; @@ -457,6 +459,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _sessionGeneration = 0; private _pendingStart: Promise | undefined; private _pendingStop: Promise | undefined; + private _pendingLocalTeardown: Promise | undefined; /** Drains the capture worklet's trailing buffer; see {@link IPcmCaptureNode.flush}. */ private _flushCapture: (() => Promise) | undefined; @@ -825,6 +828,12 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } private async _startEntitled(window: Window & typeof globalThis, surface: ChatDictationSurface, backend: DictationBackend, generation: number, startGeneration: number): Promise { + if (backend === 'nemo') { + await this._pendingLocalTeardown; + if (!this._isCurrentStart(generation, startGeneration, backend)) { + return; + } + } const captureWindow = getMediaCaptureWindow(window); this._sessionStartMs = Date.now(); this._sessionSegments = 0; @@ -1646,7 +1655,24 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo ]); return this._transcript; } - return this._localTranscription.stop(); + const stop = this._localTranscription.stop(); + const finalText = await raceTimeout(stop, NEMO_FINAL_TIMEOUT_MS); + if (finalText !== undefined) { + return finalText; + } + this._logService.warn(`[chat-stt] on-device final transcription timed out after ${NEMO_FINAL_TIMEOUT_MS}ms; using streamed transcript`); + const cancel = this._localTranscription.cancel(); + const teardown = Promise.all([ + stop.catch(error => this._logService.warn('[chat-stt] on-device final transcription failed after timing out', error)), + cancel.catch(error => this._logService.warn('[chat-stt] failed to cancel on-device transcription after finalization timeout', error)), + ]).then(() => undefined); + this._pendingLocalTeardown = teardown; + void teardown.then(() => { + if (this._pendingLocalTeardown === teardown) { + this._pendingLocalTeardown = undefined; + } + }); + return this._transcript; } async cancel(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts index 3e26f1d7c3f3a2..e4ae4ef40d3f73 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/dictationSession.css'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Emitter } from '../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; @@ -25,6 +26,11 @@ import { ChatDictationSurface, ChatSpeechToTextState, IChatSpeechToTextService } const INTERIM_PROCESSING_CLASS = 'dictation-interim-processing'; const LOG_PREFIX = '[chat-stt-dictation]'; +const MAX_DICTATION_DURATION_MS = 20 * 60 * 1000; + +function isRecording(service: IChatSpeechToTextService): boolean { + return service.state === ChatSpeechToTextState.Recording; +} /** * Renders the cumulative transcript into a code editor, replacing its own @@ -439,12 +445,21 @@ export async function startDictation(service: IChatSpeechToTextService, editor: // composer is closed); cancel dictation instead of leaving the microphone // and local transcription running against a dead editor. disposables.add(editor.onDidDispose(() => cancelDictation())); - setActiveDictation({ service, editor, inserter, disposables, logService, surface }); + const activeDictation = { service, editor, inserter, disposables, logService, surface }; + setActiveDictation(activeDictation); try { await service.start(window, surface); + if (_active === activeDictation && isRecording(service)) { + const durationLimit = window.setTimeout(() => { + logService.info(`${LOG_PREFIX} stopping after maximum duration`); + status(localize('chatStt.maximumDurationReached', "Dictation stopped after 20 minutes.")); + void stopDictation(); + }, MAX_DICTATION_DURATION_MS); + disposables.add(toDisposable(() => window.clearTimeout(durationLimit))); + } } catch { // Acquisition/connection failure is surfaced by the service. - if (_active?.service === service) { + if (_active === activeDictation) { setActiveDictation(undefined); } disposables.dispose(); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index 80eee71f9df09d..5a45016767a4f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import sinon from 'sinon'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; @@ -50,6 +51,19 @@ type ConnectionTestService = { _awaitVoiceConnected: () => Promise; }; +type FinalizationTestService = { + _activeBackend: 'nemo'; + _localTranscription: { + stop: () => Promise; + cancel: () => Promise; + }; + _finalizedText: string; + _deltaText: string; + _logService: Pick; + _pendingLocalTeardown: Promise | undefined; + _finishBackend: () => Promise; +}; + suite('ChatSpeechToTextService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -127,6 +141,43 @@ suite('ChatSpeechToTextService', () => { sessionDisposables.dispose(); }); + test('returns the streamed transcript when on-device finalization times out', async () => { + const clock = sinon.useFakeTimers(); + const warnings: string[] = []; + const stop = new DeferredPromise(); + let cancellations = 0; + const service = Object.create(ChatSpeechToTextService.prototype) as FinalizationTestService; + service._activeBackend = 'nemo'; + service._finalizedText = 'streamed transcript'; + service._deltaText = ''; + service._logService = { warn: message => warnings.push(message) }; + service._localTranscription = { + stop: () => stop.p, + cancel: async () => { cancellations++; }, + }; + + try { + const resultPromise = service._finishBackend(); + await clock.tickAsync(8000); + + assert.deepStrictEqual({ + result: await resultPromise, + cancellations, + teardownPending: service._pendingLocalTeardown !== undefined, + warnings, + }, { + result: 'streamed transcript', + cancellations: 1, + teardownPending: true, + warnings: ['[chat-stt] on-device final transcription timed out after 8000ms; using streamed transcript'], + }); + stop.complete(''); + await service._pendingLocalTeardown; + } finally { + clock.restore(); + } + }); + test('resolves the dictation language from Voice Mode configuration, display language, and browser locale', () => { assert.deepStrictEqual({ explicit: resolveDictationLanguage('fr-FR', 'de-DE'), diff --git a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts index 02e14783e8d525..6d58c91fa7db6d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { mainWindow } from '../../../../../base/browser/window.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -120,6 +121,29 @@ suite('DictationSession', () => { assert.deepStrictEqual([interimValue, editor.getValue()], ['', transcript]); }); + test('stops and inserts the final transcript after 20 minutes', async () => { + const transcript = 'hello world'; + const { service } = createService(transcript, false); + const model = store.add(createTextModel('')); + const editor = store.add(createTestCodeEditor(model)); + const clock = sinon.useFakeTimers(); + + try { + await startDictation(service, editor, mainWindow, new NullLogService()); + await clock.tickAsync(20 * 60 * 1000); + + assert.deepStrictEqual({ + isDictating: isDictating(), + value: editor.getValue(), + }, { + isDictating: false, + value: transcript, + }); + } finally { + clock.restore(); + } + }); + test('stops only when the submitted editor owns dictation', async () => { const { service } = createService('hello world', true); const dictationEditor = store.add(createTestCodeEditor(store.add(createTextModel('')))); From d7a951c1bc6dcba555b1272a6bd03ba3e16700ba Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:29 +0200 Subject: [PATCH 31/41] Agents - add commit operation into the Changes view (#333842) * Agents - add commit operation into the Changes view * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../agentHostChangesetOperationService.ts | 2 + .../node/agentHostCommitOperationHandler.ts | 4 +- .../node/agentHostCommitOperationProvider.ts | 3 +- .../node/agentHostSyncOperationHandler.ts | 4 +- .../agentHostCommitOperationProvider.test.ts | 8 ++ .../contrib/changes/browser/changesActions.ts | 61 +++++++- .../sessionsChangesAccessibilityHelp.ts | 2 +- .../test/browser/changesActions.test.ts | 132 +++++++++++++++++- .../services/sessions/common/session.ts | 7 + 9 files changed, 213 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 67eac47b7d5d6e..78bb549841ebe6 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -13,6 +13,8 @@ import type { ChangesetOperation, ISessionGitHubState, ISessionGitState, URI } f export const IAgentHostChangesetOperationService = createDecorator('agentHostChangesetOperationService'); export const AGENT_HOST_MERGE_CHANGESET_OPERATION_ID = 'merge'; +export const AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID = 'commit'; +export const AGENT_HOST_SYNC_CHANGESET_OPERATION_ID = 'sync'; /** * Changeset operations advertised for a branch that already has a pull diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index 251b3d8b506705..418bd94a233244 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -10,7 +10,7 @@ import { localize } from '../../../nls.js'; import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; -import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { readSessionGitState, type ISessionFileDiff, type SessionState } from '../common/state/sessionState.js'; @@ -22,7 +22,7 @@ const MAX_CHANGE_SUMMARY_PROMPT_CHARS = 20_000; export class AgentHostCommitOperationHandler implements IChangesetOperationHandler { - public static readonly OPERATION_COMMIT = 'commit'; + public static readonly OPERATION_COMMIT = AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID; constructor( private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index 6d8480074a710d..76ab7b26137272 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -35,7 +35,8 @@ export class AgentHostCommitOperationContribution extends Disposable implements } getOperations({ sessionKey, changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { - if ((gitState?.uncommittedChanges ?? 0) <= 0) { + const isNewSession = this._stateManager.isUnusedDraft(sessionKey) === true; + if (!isNewSession && (gitState?.uncommittedChanges ?? 0) <= 0) { return []; } diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts index bba21e32eb9733..f9692db6e2ef8f 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts @@ -11,12 +11,12 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { readSessionGitState, type SessionState } from '../common/state/sessionState.js'; import { ILogService } from '../../log/common/log.js'; -import { IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_SYNC_CHANGESET_OPERATION_ID, IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; export class AgentHostSyncOperationHandler implements IChangesetOperationHandler { - public static readonly OPERATION_SYNC = 'sync'; + public static readonly OPERATION_SYNC = AGENT_HOST_SYNC_CHANGESET_OPERATION_ID; constructor( private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts index 970f822d8cc7dd..bb1246e2c08509 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts @@ -65,6 +65,14 @@ suite('AgentHostCommitOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), []); }); + test('advertises commit for a new session without uncommitted changes', () => { + const provider = createContribution('worktree'); + + const operations = provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }); + + assert.deepStrictEqual(operations?.map(op => op.id), ['commit']); + }); + test('advertises commit on every folder session changeset when there are uncommitted changes', () => { const provider = createContribution('folder'); diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index d297c207558671..e2ad345407b139 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -29,11 +29,11 @@ import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/d import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; -import { AGENT_HOST_PULL_REQUEST_OPERATION_IDS } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasOpenPullRequestContext, SessionHasWorkspaceContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; +import { SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../services/sessions/common/session.js'; import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IChangesViewService } from '../common/changesViewService.js'; @@ -529,7 +529,64 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme } } +export class NewSessionUncommittedChangesetOperationsActionContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.sessions.newSessionUncommittedChangesetOperationsAction'; + + constructor( + @ISessionsService sessionsService: ISessionsService, + ) { + super(); + + this._register(autorun(reader => { + const activeSession = sessionsService.activeSession.read(reader); + if (activeSession?.status.read(reader) !== SessionStatus.Untitled) { + return; + } + + const changeset = activeSession.changesets.read(reader) + ?.find(candidate => candidate.id === UNCOMMITTED_CHANGES_CHANGESET_ID && candidate.isEnabled.read(reader)); + const operations = changeset?.operations.read(reader) + .filter(operation => operation.id !== AGENT_HOST_SYNC_CHANGESET_OPERATION_ID) + .filter(operation => operation.scopes.includes(SessionChangesetOperationScope.Changeset)) ?? []; + const hasUncommittedChanges = (activeSession.workspace.read(reader)?.folders[0]?.gitRepository?.uncommittedChanges ?? 0) > 0; + + for (let index = 0; index < operations.length; index++) { + const operation = operations[index]; + const precondition = operation.status === SessionChangesetOperationStatus.Disabled + || operation.status === SessionChangesetOperationStatus.Running + || (operation.id === AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID && !hasUncommittedChanges) + ? ContextKeyExpr.false() + : undefined; + + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.contrib.sessions.newSessionUncommittedChangesetOperation.${operation.id}`, + title: operation.label, + tooltip: operation.description, + icon: operation.icon, + precondition, + f1: false, + menu: { + id: Menus.SessionsEditorHeaderLayout, + group: 'navigation', + order: index, + when: ActiveEditorContext.isEqualTo(SessionChangesEditor.ID), + } + }); + } + + async run(): Promise { + await changeset?.invokeOperation(operation.id); + } + })); + } + })); + } +} + registerWorkbenchContribution2(ChangesMultiDiffSourceResolverContribution.ID, ChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChangesetOperationsActionControllerContribution.ID, ChangesetOperationsActionControllerContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(NewSessionUncommittedChangesetOperationsActionContribution.ID, NewSessionUncommittedChangesetOperationsActionContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ViewAllChangesActionViewItemContribution.ID, ViewAllChangesActionViewItemContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionChangesStatsCacheContribution.ID, SessionChangesStatsCacheContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts index 8dfb61ea1ce616..4cb51af3c79500 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -34,7 +34,7 @@ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplemen content.push(localize('sessionsChanges.tree', "Use the up and down arrow keys to move between changed files, and the left and right arrow keys to collapse or expand folders. Press Enter to open the selected file's diff.")); content.push(localize('sessionsChanges.checks', "The Checks section lists the continuous integration checks for the session's pull request. Its header is a button: press Enter or Space to collapse or expand it{0}.", '')); content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); - content.push(localize('sessionsChanges.operations', "When available, the toolbar also provides actions to commit, merge, sync, or create a pull request. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); + content.push(localize('sessionsChanges.operations', "When available, the Changes toolbar or editor title bar also provides actions to commit, merge, sync, or create a pull request. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); content.push(layoutService.isSinglePaneLayoutEnabled ? localize('sessionsChanges.diffView.singlePane', "File diffs can prefer side-by-side or inline layout. Unless screen reader optimized mode is enabled, side-by-side diffs automatically use inline layout when space is limited. Use Always Show Inline Diff in the editor title bar's More Actions menu, or use the Toggle Preferred Diff View command to switch the preference{0}.", '') : localize('sessionsChanges.diffView.classic', "File diffs can use side-by-side or inline layout. Use Inline View in the editor title area's More Actions menu, or use the Toggle Inline View command to switch the layout{0}.", '')); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts index 1a1426306598e8..e1d4394b0c4ca9 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts @@ -4,16 +4,28 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { Context } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ActiveEditorContext } from '../../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionChangeset, ISessionChangesetOperation, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../common/changes.js'; -import '../../browser/changesActions.js'; +import { NewSessionUncommittedChangesetOperationsActionContribution } from '../../browser/changesActions.js'; +import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; suite('Changes Actions', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('changes pill stays out of the pill row for a session without a workspace folder', () => { const item = MenuRegistry.getMenuItems(Menus.SessionHeaderMeta) @@ -43,4 +55,120 @@ suite('Changes Actions', () => { workspaceSessionWithoutChanges: false, }); }); + + test('draft session contributes uncommitted changeset operations to the editor header', async () => { + const invokedOperations: string[] = []; + const operations = observableValue('test.operations', [{ + id: AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, + label: 'Commit', + description: 'Commit uncommitted changes', + icon: Codicon.check, + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }, { + id: 'discard-file', + label: 'Discard File', + scopes: [SessionChangesetOperationScope.Resource], + status: SessionChangesetOperationStatus.Idle, + }, { + id: AGENT_HOST_SYNC_CHANGESET_OPERATION_ID, + label: 'Sync Changes', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Disabled, + }]); + const changeset = upcastPartial({ + id: UNCOMMITTED_CHANGES_CHANGESET_ID, + label: 'Uncommitted Changes', + isEnabled: constObservable(true), + operations, + invokeOperation: async operationId => { + invokedOperations.push(operationId); + }, + }); + const status = observableValue('test.status', SessionStatus.Untitled); + const workspace = observableValue('test.workspace', upcastPartial({ + folders: [upcastPartial({ + gitRepository: upcastPartial({ + uncommittedChanges: 0, + }), + })], + })); + const activeSession = observableValue('test.activeSession', upcastPartial({ + resource: URI.parse('test-session:draft'), + status, + workspace, + changesets: constObservable([changeset]), + })); + const sessionsService = new class extends mock() { + override readonly activeSession = activeSession; + }(); + disposables.add(new NewSessionUncommittedChangesetOperationsActionContribution(sessionsService)); + + const actionPrefix = 'workbench.contrib.sessions.newSessionUncommittedChangesetOperation.'; + const getActions = () => MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderLayout) + .filter(isIMenuItem) + .filter(item => item.command.id.startsWith(actionPrefix)); + const disabledActions = getActions(); + const disabledCommitAction = disabledActions.find(item => item.command.id === `${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`)!; + const disabledWithoutChanges = disabledCommitAction.command.precondition?.serialize(); + workspace.set(upcastPartial({ + folders: [upcastPartial({ + gitRepository: upcastPartial({ + uncommittedChanges: 1, + }), + })], + }), undefined); + const actions = getActions(); + const commitAction = actions.find(item => item.command.id === `${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`)!; + const context = new Context(1, null); + context.setValue(ActiveEditorContext.key, SessionChangesEditor.ID); + const visibleForChangesTab = commitAction.when?.evaluate(context) ?? true; + context.setValue(ActiveEditorContext.key, 'workbench.editors.textEditor'); + const visibleForTextTab = commitAction.when?.evaluate(context) ?? true; + const instantiationService = disposables.add(new TestInstantiationService()); + await instantiationService.invokeFunction(CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`)!.handler); + + assert.deepStrictEqual({ + actions: actions.map(item => ({ + id: item.command.id, + title: item.command.title, + tooltip: item.command.tooltip, + icon: item.command.icon, + group: item.group, + order: item.order, + precondition: item.command.precondition?.serialize(), + })), + invokedOperations, + disabledWithoutChanges, + visibleForChangesTab, + visibleForTextTab, + resourceOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}discard-file`) !== undefined, + syncOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_SYNC_CHANGESET_OPERATION_ID}`) !== undefined, + }, { + actions: [{ + id: `${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`, + title: 'Commit', + tooltip: 'Commit uncommitted changes', + icon: Codicon.check, + group: 'navigation', + order: 0, + precondition: undefined, + }], + invokedOperations: [AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID], + disabledWithoutChanges: 'false', + visibleForChangesTab: true, + visibleForTextTab: false, + resourceOperationRegistered: false, + syncOperationRegistered: false, + }); + + status.set(SessionStatus.Completed, undefined); + assert.deepStrictEqual({ + menuActions: getActions().length, + commitCommandRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID}`) !== undefined, + }, { + menuActions: 0, + commitCommandRegistered: false, + }); + }); }); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 0c648fb4f4c8a1..e18e23c2dfa465 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -431,6 +431,13 @@ export type ISessionTurnFileChange = ISessionFileChange & { */ export const BRANCH_CHANGES_CHANGESET_ID = 'branchChanges'; +/** + * Well-known id of the changeset that holds uncommitted working-tree changes. + * + * Must match the agent host provider's `ChangesetKind.Uncommitted` value. + */ +export const UNCOMMITTED_CHANGES_CHANGESET_ID = 'uncommitted'; + /** * Well-known id of the changeset that holds the diff made during the session's * **last turn** only (as opposed to the cumulative session diff). Consumers that From fef8ec347e57aaa5b1f8bcb2377bb6bd84111bbc Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:39:14 +0200 Subject: [PATCH 32/41] Agents - move the view as list/tree actions one level lower (#333857) --- .../changes/browser/changesViewActions.ts | 9 +- .../test/browser/changesViewActions.test.ts | 82 ++++++++++--------- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index d32d1ef33b99fd..addcd03f330ed2 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -198,7 +198,6 @@ class ChangesHeaderActionsAction extends Action2 { registerAction2(ChangesHeaderActionsAction); - class SetChangesListViewModeAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.setChangesListViewMode'; @@ -209,8 +208,8 @@ class SetChangesListViewModeAction extends Action2 { icon: Codicon.listFlat, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '2_viewMode', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/2_viewMode', order: 20, when: ContextKeyExpr.and( singlePaneDiffEditorTitle, @@ -238,8 +237,8 @@ class SetChangesTreeViewModeAction extends Action2 { icon: Codicon.listTree, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '2_viewMode', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/2_viewMode', order: 20, when: ContextKeyExpr.and( singlePaneDiffEditorTitle, diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index e76ca3cccdbaf7..8b2c81ee766370 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -292,45 +292,45 @@ suite('Changes View Actions', () => { assert.strictEqual(getChangesAccessibilityHelp(false).includes('Use Inline View in the editor title area\'s More Actions menu'), true); }); - test('view mode toggles are contributed to the editor title bar overflow for non-text single-file diffs', () => { - const items = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + test('view mode toggles are moved to the editor header layout overflow for non-text single-file diffs', () => { + const getItems = (menuId: MenuId) => MenuRegistry.getMenuItems(menuId) .filter(isIMenuItem) - .filter(item => item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' || item.command.id === 'workbench.action.agentSessions.setChangesTreeViewMode'); + .filter(item => item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' || item.command.id === 'workbench.action.agentSessions.setChangesTreeViewMode') + .map(item => { + const when = item.when?.serialize() ?? ''; + const context = new Context(1, null); + context.setValue(IsSessionsWindowContext.key, true); + context.setValue(SinglePaneDiffEditorInputActiveContext.key, true); + context.setValue(SinglePaneLayoutEnabledContext.key, true); + context.setValue(IsAuxiliaryWindowContext.key, false); + context.setValue(IsTopRightEditorGroupContext.key, true); + context.setValue(AuxiliaryBarVisibleContext.key, true); + context.setValue( + ChangesContextKeys.ViewMode.key, + item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' ? ChangesViewMode.Tree : ChangesViewMode.List + ); + return { + id: item.command.id, + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), + hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), + hasDiffEditorInputGate: when.includes(SinglePaneDiffEditorInputActiveContext.key), + hasSinglePaneConfigGate: when.includes(SinglePaneLayoutEnabledContext.key), + hasAuxBarVisibleGate: when.includes(AuxiliaryBarVisibleContext.key), + hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + hasViewModeGate: when.includes(ChangesContextKeys.ViewMode.key), + matchesSingleFileDiffContext: item.when?.evaluate(context) ?? false, + }; + }) + .sort((a, b) => a.id.localeCompare(b.id)); - const actual = items.map(item => { - const when = item.when?.serialize() ?? ''; - const context = new Context(1, null); - context.setValue(IsSessionsWindowContext.key, true); - context.setValue(SinglePaneDiffEditorInputActiveContext.key, true); - context.setValue(SinglePaneLayoutEnabledContext.key, true); - context.setValue(IsAuxiliaryWindowContext.key, false); - context.setValue(IsTopRightEditorGroupContext.key, true); - context.setValue(AuxiliaryBarVisibleContext.key, true); - context.setValue( - ChangesContextKeys.ViewMode.key, - item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' ? ChangesViewMode.Tree : ChangesViewMode.List - ); - return { - id: item.command.id, - title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, - group: item.group, - order: item.order, - icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, - hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), - hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), - hasDiffEditorInputGate: when.includes(SinglePaneDiffEditorInputActiveContext.key), - hasSinglePaneConfigGate: when.includes(SinglePaneLayoutEnabledContext.key), - hasAuxBarVisibleGate: when.includes(AuxiliaryBarVisibleContext.key), - hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), - hasViewModeGate: when.includes(ChangesContextKeys.ViewMode.key), - matchesSingleFileDiffContext: item.when?.evaluate(context) ?? false, - }; - }).sort((a, b) => a.id.localeCompare(b.id)); - - assert.deepStrictEqual(actual, [{ + const expectedItems = (group: string) => [{ id: 'workbench.action.agentSessions.setChangesListViewMode', title: 'View as List', - group: '2_viewMode', + group, order: 20, icon: Codicon.listFlat.id, hasSessionsWindowGate: true, @@ -344,7 +344,7 @@ suite('Changes View Actions', () => { }, { id: 'workbench.action.agentSessions.setChangesTreeViewMode', title: 'View as Tree', - group: '2_viewMode', + group, order: 20, icon: Codicon.listTree.id, hasSessionsWindowGate: true, @@ -355,7 +355,15 @@ suite('Changes View Actions', () => { hasEditorAreaVisibleGate: false, hasViewModeGate: true, matchesSingleFileDiffContext: true, - }]); + }]; + + assert.deepStrictEqual({ + headerLayout: getItems(Menus.SessionsEditorHeaderLayout), + editorTitleOverflow: getItems(Menus.SessionsEditorTitle), + }, { + headerLayout: expectedItems('secondary/2_viewMode'), + editorTitleOverflow: [], + }); }); test('Create Pull Request anchor is visible for created sessions but hidden for custom views', () => { From b8b400c377dd4a55b5df225d31bff2dcdb293822 Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:45:57 -0700 Subject: [PATCH 33/41] accounts: avoid more redundant managed settings requests (#333823) * accounts: avoid redundant managed settings requests Preserve a fresh managed-settings cache during entitlement refreshes, deduplicate authentication sessions that match overlapping scope alternatives, and treat a managed-settings 404 as a final no-policy result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * accounts: scope forced account refreshes Replace the managed-settings cache exception with explicit refresh targets. Chat entitlement updates now force only account entitlements, while policy sync and retry actions force only managed settings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * accounts: simplify scoped refresh options Keep only the entitlement and managed-settings refresh modes used by production callers. Remove the general target enum, all mode, helper, and unused token and MCP refresh parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * accounts: keep force refresh explicit Restore forceRefresh as a simple all-account-data boolean. Normal chat and managed-settings source updates now use ordinary cache-aware refreshes; only explicit policy sync and retry actions force all caches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * accounts: preserve explicit entitlement refreshes Keep quota and upgrade checks fresh without forcing unrelated default-account caches. The all-or-nothing forceRefresh boolean remains reserved for explicit policy sync and retry actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * accounts: test governed entitlement refresh Exercise the full account refresh path with a scoped, satisfied forceRemoteSettingsRefresh cache. Verify entitlement refresh requests only entitlement data and preserves satisfied managed-settings freshness before full force refresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * accounts: trim managed settings regression diff Restore unrelated test wording and retry documentation, inline the managed-settings force check, and narrow the governed entitlement regression to the behavior under review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../defaultAccount/common/defaultAccount.ts | 2 + .../accounts/browser/defaultAccount.ts | 37 +++--- .../test/browser/defaultAccount.test.ts | 114 ++++++++++++++++++ .../chat/common/chatEntitlementService.ts | 2 +- 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 784bf55e050c68..e4839aeed658d8 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -38,6 +38,8 @@ export interface IManagedSettingsCompatibilityError { export interface IDefaultAccountRefreshOptions { readonly forceRefresh?: boolean; + /** Refreshes entitlement data even when its cache is fresh. */ + readonly refreshEntitlements?: boolean; /** Allows an explicit user action to retry managed settings after a failed attempt. */ readonly retryManagedSettings?: boolean; } diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 36b9667d6b400b..23f0dbb85c1ae2 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -273,6 +273,11 @@ interface IManagedSettingsSources { readonly file: ManagedSettingsData; } +interface IAuthenticatedRequestOptions { + readonly requestTimeoutMs?: number; + readonly retryNotFound?: boolean; +} + type DefaultAccountStatusTelemetry = { status: string; initial: boolean; @@ -695,7 +700,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private onManagedSettingsSourceChanged(): void { if (this.initialized) { - void this.updateDefaultAccount({ forceRefresh: true }); + void this.updateDefaultAccount(); } } @@ -914,15 +919,10 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private async findMatchingProviderSession(authProviderId: string, allScopes: string[][]): Promise { const sessions = await this.getSessions(authProviderId); - const matchingSessions = []; - for (const session of sessions) { + const matchingSessions = sessions.filter(session => { this.logService.debug('[DefaultAccount] Checking session with scopes', session.scopes); - for (const scopes of allScopes) { - if (this.scopesMatch(session.scopes, scopes)) { - matchingSessions.push(session); - } - } - } + return allScopes.some(scopes => this.scopesMatch(session.scopes, scopes)); + }); return matchingSessions.length > 0 ? matchingSessions : undefined; } @@ -1016,7 +1016,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private async getEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: IDefaultAccountRefreshOptions): Promise<{ data: IEntitlementsData | undefined | null; fetchedAt: number | undefined }> { const accountId = sessions[0].account.id; const existingData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; - if (!options?.forceRefresh && existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { + if (!options?.forceRefresh && !options?.refreshEntitlements && existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched entitlements data'); return { data: existingData, fetchedAt: accountPolicyData.entitlementsFetchedAt }; } @@ -1169,7 +1169,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const freshnessSatisfied = requirement.effective && isManagedSettingsFreshnessSatisfiedFor(this._managedSettingsFreshness, scope); // When forceRemoteSettingsRefresh is effective, reuse also requires this scope's freshness to be // satisfied; an outstanding compatibility error always forces revalidation. - if (!options?.forceRefresh && scopedCachedManagedSettings && (!requirement.effective || freshnessSatisfied) && !this._managedSettingsCompatibilityError) { + if (!options?.forceRefresh && !options?.retryManagedSettings && scopedCachedManagedSettings && (!requirement.effective || freshnessSatisfied) && !this._managedSettingsCompatibilityError) { this.logService.debug('[DefaultAccount] Using last fetched managed settings data'); return { ...scopedCachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; } @@ -1298,7 +1298,10 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const requestUrl = appendManagedSettingsClientIdentity(managedSettingsUrl, this.productService); this.logService.debug('[DefaultAccount] Fetching managed settings from:', requestUrl); const rateLimitBackoffActive = Date.now() < this._rateLimitBackoffUntil; - const response = await this.request(requestUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', MANAGED_SETTINGS_REQUEST_TIMEOUT_MS); + const response = await this.request(requestUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', { + requestTimeoutMs: MANAGED_SETTINGS_REQUEST_TIMEOUT_MS, + retryNotFound: false, + }); if (!response) { this.logService.debug('[DefaultAccount] Managed settings fetch returned no response (network error, all selected sessions rejected, or active rate-limit backoff); falling back to local-only policy'); this.reportManagedSettingsOutcome('no-response', rateLimitBackoffActive); @@ -1441,9 +1444,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private _rateLimitBackoffUntil = 0; - private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; - private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; - private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise { + private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise; + private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise; + private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise { if (Date.now() < this._rateLimitBackoffUntil) { const remainingSec = Math.ceil((this._rateLimitBackoffUntil - Date.now()) / 1000); this.logService.debug(`[DefaultAccount] Skipping request to ${url} — rate-limit backoff active for ${remainingSec}s more`); @@ -1463,7 +1466,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun url, data: type === 'POST' ? JSON.stringify(body) : undefined, disableCache: true, - timeout: requestTimeoutMs, + timeout: options?.requestTimeoutMs, headers: { 'Authorization': `Bearer ${session.accessToken}` }, @@ -1477,7 +1480,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.logService.warn(`[DefaultAccount] Rate limited by ${url} (status ${status}); backing off for ${retryAfterSec}s`); return response; } - if (status === 401 || status === 404) { + if (status === 401 || (status === 404 && options?.retryNotFound !== false)) { this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; continue; // try next session diff --git a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts index e1b3af95aadd65..95cabd8ef6bcc1 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -112,6 +112,78 @@ suite('DefaultAccountProvider', () => { }); }); + test('entitlement refresh preserves satisfied governed managed settings', async () => { + const freshEntitlements = { + access_type_sku: 'copilot_business_seat', + chat_enabled: true, + assigned_date: '2026-01-01', + can_signup_for_limited: false, + copilot_plan: 'business', + organization_login_list: [], + analytics_tracking_id: 'tracking-id', + }; + const requestService = new TestRequestService(async options => options.callSite === 'defaultAccount.entitlements' + ? jsonResponse(freshEntitlements) + : Promise.reject(new Error(`Unexpected request: ${options.url}`))); + const provider = await createProvider(requestService); + const fetchedAt = Date.now() - 1000; + const managedSettingsScope = { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }; + const cachedPolicy = { + ...createCachedPolicy(true), + managedSettingsScope, + }; + const policyData = { + ...cachedPolicy, + entitlementsFetchedAt: fetchedAt, + managedSettingsFetchedAt: fetchedAt, + }; + provider['setDefaultAccount']({ + defaultAccount: { + authenticationProvider: { id: 'github', name: 'GitHub', enterprise: false }, + accountName: sessions[0].account.label, + sessionId: sessions[0].id, + enterprise: false, + entitlementsData: { ...freshEntitlements, copilot_plan: 'individual' }, + }, + accountId, + policyData, + copilotTokenInfo: null, + }); + provider['setManagedSettingsFreshness']({ + state: ManagedSettingsFreshnessState.Satisfied, + source: 'server', + scope: managedSettingsScope, + lastAttemptAt: fetchedAt, + satisfiedAt: fetchedAt, + }); + + const refreshedEntitlements = await provider['getDefaultAccountFromAuthenticatedSessions']( + { id: 'github', name: 'GitHub', enterprise: false }, + sessions, + { refreshEntitlements: true } + ); + + assert.deepStrictEqual({ + callSites: requestService.requests.map(request => request.callSite), + refreshedCopilotPlan: refreshedEntitlements?.defaultAccount.entitlementsData?.copilot_plan, + freshness: describeFreshness(provider.managedSettingsFreshness), + }, { + callSites: ['defaultAccount.entitlements'], + refreshedCopilotPlan: 'business', + freshness: { + state: ManagedSettingsFreshnessState.Satisfied, + source: 'server', + scope: managedSettingsScope, + hasLastAttempt: true, + hasSatisfiedAt: true, + }, + }); + }); + test('settings without a refresh requirement refetch only after the cache becomes stale', async () => { const requestService = new TestRequestService(async () => jsonResponse({})); const provider = await createProvider(requestService); @@ -278,6 +350,26 @@ suite('DefaultAccountProvider', () => { }); }); + test('managed settings 404 does not retry with another authentication session', async () => { + const requestService = new TestRequestService(async () => jsonResponse({}, 404)); + const provider = await createProvider(requestService); + + const result = await provider['getManagedSettings']([ + sessions[0], + { ...sessions[0], id: 'second-session' }, + ], undefined); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + status: provider.managedSettingsFetchStatus, + data: result.data, + }, { + requestCount: 1, + status: 404, + data: { managedSettings: undefined }, + }); + }); + test('fresh 404 satisfies a native refresh requirement', async () => { const requestService = new TestRequestService(async () => jsonResponse({}, 404)); const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); @@ -557,6 +649,28 @@ suite('DefaultAccountProvider', () => { assert.strictEqual(requestService.requestCount, 2); }); + test('matching authentication sessions are not duplicated by overlapping accepted scopes', async () => { + const broadSession: AuthenticationSession = { + ...sessions[0], + scopes: ['read:user', 'user:email', 'repo', 'workflow'], + }; + const provider = await createProvider( + new TestRequestService(async () => jsonResponse({})), + {}, + {}, + '', + { getSessions: async () => [broadSession] } + ); + + const matching = await provider['findMatchingProviderSession']('github', [ + ['read:user', 'user:email', 'repo', 'workflow'], + ['user:email'], + ['read:user'], + ]); + + assert.deepStrictEqual(matching?.map(session => session.id), ['session']); + }); + test('first server response can establish and satisfy a refresh requirement', async () => { const requestService = new TestRequestService(async () => jsonResponse({ forceRemoteSettingsRefresh: true, diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 9d6f333acc8bf1..c84b31cf634f76 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -1221,7 +1221,7 @@ export class ChatEntitlementRequests extends Disposable { } async forceResolveEntitlement(token = CancellationToken.None): Promise { - const defaultAccount = await this.defaultAccountService.refresh({ forceRefresh: true }); + const defaultAccount = await this.defaultAccountService.refresh({ refreshEntitlements: true }); if (!defaultAccount) { return undefined; } From ebbbc9e814f7a13ecc5401ed221a3e98edc4ea4e Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 1 Sep 2026 17:11:54 -0400 Subject: [PATCH 34/41] Handle warning and info messages froom SDK (#333851) * Handle warning and info messages froom SDK * fix ci --- .../agentHost/common/agentModelNotices.ts | 64 +++++++++++++++++++ .../agentHost/node/copilot/copilotAgent.ts | 8 +-- .../agentHost/test/node/copilotAgent.test.ts | 27 +++++++- .../agentHostLanguageModelProvider.ts | 10 ++- .../agentHostLanguageModelProvider.test.ts | 23 +++++++ 5 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentModelNotices.ts diff --git a/src/vs/platform/agentHost/common/agentModelNotices.ts b/src/vs/platform/agentHost/common/agentModelNotices.ts new file mode 100644 index 00000000000000..2fb7721b657377 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentModelNotices.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IAgentModelInfo } from './agent.js'; +import type { SessionModelInfo } from './state/protocol/state.js'; + +const DATA_RETENTION_WARNING_CODE = 'data_retention'; +const PENDING_DEPRECATION_WARNING_CODE = 'model_pending_deprecation'; + +type ModelMessage = { readonly code: string; readonly message: string }; +type ModelNoticeSource = { + readonly warningText?: { readonly dataRetention?: string }; + readonly infoMessages?: readonly ModelMessage[]; + readonly warningMessages?: readonly ModelMessage[]; +}; + +/** Converts SDK model messages to model-picker metadata. */ +export function createAgentModelNoticesMeta(source: ModelNoticeSource): Record | undefined { + const warningText: Record = {}; + const infoText: Record = {}; + if (source.warningText?.dataRetention) { + warningText[DATA_RETENTION_WARNING_CODE] = source.warningText.dataRetention; + } + for (const { code, message } of source.infoMessages ?? []) { + if (message) { + const target = code === PENDING_DEPRECATION_WARNING_CODE ? warningText : infoText; + target[code || 'info'] = message; + } + } + for (const { code, message } of source.warningMessages ?? []) { + if (message) { + warningText[code || 'warning'] = message; + } + } + const rowWarning = source.warningMessages?.find(({ message }) => !!message)?.message + ?? warningText[PENDING_DEPRECATION_WARNING_CODE]; + const result = { + ...(Object.keys(warningText).length > 0 ? { warningText } : {}), + ...(Object.keys(infoText).length > 0 ? { infoText } : {}), + ...(rowWarning ? { rowWarning } : {}), + }; + return Object.keys(result).length > 0 ? result : undefined; +} + +/** Reads model-picker messages from Agent Host metadata. */ +export function readAgentModelNoticesMeta(model: IAgentModelInfo | SessionModelInfo) { + const meta = model._meta; + return { + warningText: asStringDictionary(meta?.warningText), + infoText: asStringDictionary(meta?.infoText), + rowWarning: typeof meta?.rowWarning === 'string' && meta.rowWarning.length > 0 ? meta.rowWarning : undefined, + }; +} + +function asStringDictionary(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const entries = Object.entries(value).filter((entry): entry is [string, string] => + entry[0].length > 0 && typeof entry[1] === 'string' && entry[1].length > 0); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 0c41480a02b593..848066cc5cb140 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -36,6 +36,7 @@ import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointSer import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; +import { createAgentModelNoticesMeta } from '../../common/agentModelNotices.js'; import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, DEFAULT_COPILOT_RUBBER_DUCK_ENABLED, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; @@ -2296,11 +2297,10 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - /** - * Builds the open `_meta` model picker bag from the SDK's billing and picker metadata. - */ private _createModelPickerMeta(modelInfo: CopilotModelInfo, billing: ICAPIModelBilling | undefined): Record | undefined { - return createPricingMetaFromBilling(billing, modelInfo.modelPickerPriceCategory, modelInfo.modelPickerCategory); + const pricing = createPricingMetaFromBilling(billing, modelInfo.modelPickerPriceCategory, modelInfo.modelPickerCategory); + const notices = isAutoModel(modelInfo.id) ? undefined : createAgentModelNoticesMeta(modelInfo); + return pricing || notices ? { ...pricing, ...notices } : undefined; } private _createModelConfigSchema(m: CopilotModelInfo, billing: ICAPIModelBilling | undefined): ConfigSchema | undefined { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 78b55f4fe2f34c..47e6020ba4ba14 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -429,6 +429,9 @@ interface ITestCopilotModelInfo { readonly billing?: CopilotModelInfo['billing']; readonly modelPickerCategory?: CopilotModelInfo['modelPickerCategory']; readonly modelPickerPriceCategory?: CopilotModelInfo['modelPickerPriceCategory']; + readonly warningText?: CopilotModelInfo['warningText']; + readonly infoMessages?: CopilotModelInfo['infoMessages']; + readonly warningMessages?: CopilotModelInfo['warningMessages']; readonly supportedReasoningEfforts?: CopilotModelInfo['supportedReasoningEfforts']; } @@ -469,6 +472,9 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): CopilotModelInfo { ...(model.billing ? { billing: model.billing } : {}), ...(model.modelPickerCategory ? { modelPickerCategory: model.modelPickerCategory } : {}), ...(model.modelPickerPriceCategory ? { modelPickerPriceCategory: model.modelPickerPriceCategory } : {}), + ...(model.warningText ? { warningText: model.warningText } : {}), + ...(model.infoMessages ? { infoMessages: model.infoMessages } : {}), + ...(model.warningMessages ? { warningMessages: model.warningMessages } : {}), ...(model.supportedReasoningEfforts ? { supportedReasoningEfforts: model.supportedReasoningEfforts } : {}), }; } @@ -4853,7 +4859,7 @@ suite('CopilotAgent', () => { } }); - test('models include picker and promo metadata when the SDK provides it', async () => { + test('models include picker, notice, and promo metadata when the SDK provides it', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', @@ -4878,6 +4884,16 @@ suite('CopilotAgent', () => { }, modelPickerCategory: 'powerful', modelPickerPriceCategory: 'medium', + warningText: { + dataRetention: 'Prompts are retained for 30 days.', + }, + infoMessages: [ + { code: 'model_pending_deprecation', message: 'Claude Sonnet will be retired soon.' }, + { code: 'model_relocated', message: 'Claude Sonnet now serves from a new region.' }, + ], + warningMessages: [ + { code: 'model_degraded', message: 'Claude Sonnet is currently degraded.' }, + ], }]), }); try { @@ -4894,6 +4910,15 @@ suite('CopilotAgent', () => { longContextOutputCost: 22.5, priceCategory: 'medium', category: 'powerful', + warningText: { + data_retention: 'Prompts are retained for 30 days.', + model_pending_deprecation: 'Claude Sonnet will be retired soon.', + model_degraded: 'Claude Sonnet is currently degraded.', + }, + infoText: { + model_relocated: 'Claude Sonnet now serves from a new region.', + }, + rowWarning: 'Claude Sonnet is currently degraded.', promo: { id: 'summer-sale', discountPercent: 25, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index dd3277b73509e9..bae6b6cc485f05 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; +import { readAgentModelNoticesMeta } from '../../../../../../platform/agentHost/common/agentModelNotices.js'; import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { readAgentModelPricingMeta } from '../../../../../../platform/agentHost/common/agentModelPricing.js'; import { readAgentModelByokIdentifier } from '../../../../../../platform/agentHost/common/agentModelByokMeta.js'; @@ -68,6 +70,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu const multiplierNumeric = pricing.multiplierNumeric; // "Auto" advertises the auto-mode discount (detail) + description (tooltip). microsoft/vscode#321778, #321659. const isAuto = m.id === AUTO_RAW_MODEL_ID; + const notices = isAuto ? undefined : readAgentModelNoticesMeta(m); const discountPercent = pricing.discountPercent; // Guard against a non-finite or out-of-range value from the open `_meta` bag so we never render // nonsense like "Infinity% discount"; the documented range is a whole number in (0, 100]. @@ -75,9 +78,9 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu const detail = isAuto && hasDiscount ? localize('agentHost.auto.discount', "{0}% discount", discountPercent) : undefined; - const tooltip = isAuto + const tooltip = notices?.rowWarning ?? (isAuto ? ILanguageModelChatMetadata.getAutoModelDescription(hasDiscount ? discountPercent : undefined) - : undefined; + : undefined); const modelGroup = this._modelGroupFor(m); const byokModelIdentifier = readAgentModelByokIdentifier(m); return { @@ -95,6 +98,9 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu maxOutputTokens: m.maxOutputTokens ?? 0, isDefaultForLocation: {}, isUserSelectable: true, + statusIcon: notices?.rowWarning ? Codicon.warning : undefined, + warningText: notices?.warningText, + infoText: notices?.infoText, pricing: multiplierNumeric !== undefined ? `${multiplierNumeric}x` : undefined, multiplierNumeric, inputCost: pricing.inputCost, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index eb86eab00d0248..4325473e400aab 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; @@ -136,6 +137,28 @@ suite('AgentHostLanguageModelProvider', () => { ]); }); + test('carries model notices and flags row warnings', async () => { + const provider = createProvider(); + provider.updateModels([makeModel('gpt-5', { + warningText: { model_degraded: 'GPT-5 is currently degraded.' }, + infoText: { model_relocated: 'GPT-5 now serves from a new region.' }, + rowWarning: 'GPT-5 is currently degraded.', + })]); + + const metadata = (await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None))[0].metadata; + assert.deepStrictEqual({ + tooltip: metadata.tooltip, + statusIcon: metadata.statusIcon?.id, + warningText: metadata.warningText, + infoText: metadata.infoText, + }, { + tooltip: 'GPT-5 is currently degraded.', + statusIcon: Codicon.warning.id, + warningText: { model_degraded: 'GPT-5 is currently degraded.' }, + infoText: { model_relocated: 'GPT-5 now serves from a new region.' }, + }); + }); + test('derives the picker group from the model-id prefix, not the harness provider', async () => { const provider = createProvider(); // The agent host reports every model under the harness provider (`copilotcli`); From 418372b8b4e1bbc7403e0f6b2108f41c2998bd0a Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:17:29 -0700 Subject: [PATCH 35/41] fix: missing note on N/A (#333862) --- build/azure-pipelines/product-build-ado-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines/product-build-ado-ci.yml b/build/azure-pipelines/product-build-ado-ci.yml index d719b96f7a0949..b2c62d5a7154de 100644 --- a/build/azure-pipelines/product-build-ado-ci.yml +++ b/build/azure-pipelines/product-build-ado-ci.yml @@ -297,7 +297,7 @@ extends: if [ "${#trailer_values[@]}" = "0" ]; then echo "##vso[task.logissue type=error]Commit $head_sha is missing the required 'Msrc-Case-Id' trailer." - printf "Add a trailer to the commit message, for example:\n\n Msrc-Case-Id: 12345\n\n" + printf "Add a trailer to the commit message, for example:\n\n Msrc-Case-Id: 12345\n\nIf there is no associated case ID, use N/A:\n\n Msrc-Case-Id: N/A\n\n" exit 1 fi From 0582939c665d449b930bbc0c83e26d4f79edd8ba Mon Sep 17 00:00:00 2001 From: Jessie Houghton <46505805+houghj16@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:31:22 -0700 Subject: [PATCH 36/41] Fix dark theme scrollbar colors customizations pages (#333634) * Agent Host changes for agents/fix-dark-theme-scrollbar-colors * Use themed scrollbars for tools and MCP servers Match the customization card scrollbar spacing and reuse the themed overlay scrollbar for MCP server cards.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix scrollbar colors for dark theme in AI customization widgets * Fix scrolled customization fixture timeout Give the scrolled fixtures enough virtual time for the scrollbar hide and fade wait to complete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix scrollbar colors for dark theme in CI screenshots documentation --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationListWidget.ts | 45 +++++++++++----- .../browser/aiCustomization/mcpListWidget.ts | 53 +++++++++++++----- .../media/aiCustomizationManagement.css | 27 +++++++++- .../aiCustomization/pluginListWidget.ts | 54 +++++++++++++++---- ...aiCustomizationManagementEditor.fixture.ts | 5 ++ .../blocks-ci-screenshots.md | 48 ++++++++--------- 6 files changed, 172 insertions(+), 60 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 3047046e652232..d395911febea47 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -49,6 +49,8 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IAICustomizationListItem } from './aiCustomizationItemSource.js'; import { IAICustomizationItemsModel, ItemsModelSection } from './aiCustomizationItemsModel.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -615,7 +617,8 @@ export class AICustomizationListWidget extends Disposable { private listContainer!: HTMLElement; private list!: WorkbenchList; private cardContainer!: HTMLElement; - private cardScrollElement: HTMLElement | undefined; + private cardScrollable!: DomScrollableElement; + private cardScrollableNode!: HTMLElement; private firstCardFocusElement: HTMLElement | undefined; private readonly cardRowsByUri = new Map(); private readonly cardRowsById = new Map(); @@ -781,7 +784,23 @@ export class AICustomizationListWidget extends Disposable { this._register(this.addButton.onDidClick(() => this.executePrimaryCreateAction())); this.cardContainer = DOM.append(this.element, $('.plugin-card-container.customization-card-container')); - this.cardContainer.style.display = 'none'; + this.cardScrollable = this._register(new DomScrollableElement(this.cardContainer, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: false, + })); + this._register(DOM.addDisposableListener(this.cardContainer, DOM.EventType.SCROLL, () => { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollTop }); + })); + this.cardScrollableNode = this.cardScrollable.getDomNode(); + this.cardScrollableNode.classList.add('plugin-card-scrollable'); + this.cardScrollableNode.style.display = 'none'; + this.element.appendChild(this.cardScrollableNode); + const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( + 'AICustomizationListWidget.cardScrollable', + () => this.cardScrollable.scanDomNode(), + )); + this._register(cardResizeObserver.observe(this.cardScrollableNode)); // List container this.listContainer = DOM.append(this.element, $('.list-container')); @@ -1572,10 +1591,9 @@ export class AICustomizationListWidget extends Disposable { this.cardRowsByUri.clear(); this.cardRowsById.clear(); this.cardMenuButtonsById.clear(); - this.cardScrollElement = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.updateEmptyState(); return; } @@ -1594,8 +1612,8 @@ export class AICustomizationListWidget extends Disposable { DOM.clearNode(this.cardContainer); this.listContainer.style.display = 'none'; this.emptyStateContainer.style.display = 'none'; - this.cardContainer.style.display = ''; - const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll.customization-card-scroll')); + this.cardScrollableNode.style.display = ''; + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content.customization-card-scroll')); for (const group of visibleGroups) { const section = DOM.append(content, $('.plugin-card-section.customization-card-section')); @@ -1628,6 +1646,7 @@ export class AICustomizationListWidget extends Disposable { } cardList.finalize(); } + this.cardScrollable.scanDomNode(); if (shouldRestoreFocus) { DOM.getWindow(this.element).requestAnimationFrame(() => { (this.cardMenuButtonsById.get(focusItemId ?? '') ?? this.cardRowsById.get(focusItemId ?? '') ?? this.firstCardFocusElement)?.focus(); @@ -1884,7 +1903,7 @@ export class AICustomizationListWidget extends Disposable { private updateEmptyState(): void { const hasItems = this.displayEntries.length > 0; if (!hasItems) { - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.emptyStateContainer.style.display = 'flex'; this.listContainer.style.display = 'none'; @@ -1901,7 +1920,7 @@ export class AICustomizationListWidget extends Disposable { } else { this.emptyStateContainer.style.display = 'none'; this.listContainer.style.display = this.usesCardLayout() ? 'none' : ''; - this.cardContainer.style.display = this.usesCardLayout() ? '' : 'none'; + this.cardScrollableNode.style.display = this.usesCardLayout() ? '' : 'none'; } } @@ -1976,9 +1995,7 @@ export class AICustomizationListWidget extends Disposable { */ revealLastItem(): void { if (this.usesCardLayout()) { - if (this.cardScrollElement) { - this.cardScrollElement.scrollTop = this.cardScrollElement.scrollHeight; - } + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); return; } if (this.displayEntries.length > 0) { @@ -2049,9 +2066,11 @@ export class AICustomizationListWidget extends Disposable { const availableHeight = this.element.clientHeight || height; const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); - this.cardContainer.style.height = `${listHeight}px`; + this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; - if (!this.usesCardLayout()) { + if (this.usesCardLayout()) { + this.cardScrollable.scanDomNode(); + } else { this.list.layout(listHeight, width); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 889110336982f5..5a5ae3ab13eda2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -55,6 +55,8 @@ import { Range } from '../../../../../editor/common/core/range.js'; import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { createWorkbenchMcpServerDetailInput, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; const $ = DOM.$; @@ -1062,6 +1064,8 @@ export class McpListWidget extends Disposable { private searchAndButtonContainer!: HTMLElement; private searchInput!: InputBox; private cardContainer!: HTMLElement; + private cardScrollable!: DomScrollableElement; + private cardScrollableNode!: HTMLElement; private emptyContainer!: HTMLElement; private emptyText!: HTMLElement; private emptySubtext!: HTMLElement; @@ -1084,7 +1088,6 @@ export class McpListWidget extends Disposable { private visible = false; private mcpAccessEnabled = false; private firstCardFocusElement: HTMLElement | undefined; - private cardScrollElement: HTMLElement | undefined; private availableSection: HTMLElement | undefined; private narrowLayout = false; private wideLayout = false; @@ -1251,8 +1254,24 @@ export class McpListWidget extends Disposable { disabledText.textContent = localize('mcpAccessDisabledTitle', "MCP servers are disabled"); this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); - this.cardContainer = DOM.append(this.element, $('.plugin-card-container')); - this.cardContainer.style.display = 'none'; + this.cardContainer = $('.plugin-card-container'); + this.cardScrollable = this._register(new DomScrollableElement(this.cardContainer, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: false, + })); + this._register(DOM.addDisposableListener(this.cardContainer, DOM.EventType.SCROLL, () => { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollTop }); + })); + this.cardScrollableNode = this.cardScrollable.getDomNode(); + this.cardScrollableNode.classList.add('plugin-card-scrollable'); + this.cardScrollableNode.style.display = 'none'; + this.element.appendChild(this.cardScrollableNode); + const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( + 'McpListWidget.cardScrollable', + () => this.cardScrollable.scanDomNode(), + )); + this._register(cardResizeObserver.observe(this.cardScrollableNode)); // Listen to MCP service changes this._register(this.mcpWorkbenchService.onChange(() => { @@ -1420,16 +1439,27 @@ export class McpListWidget extends Disposable { private showCardSurface(): void { this.emptyContainer.style.display = 'none'; - this.cardContainer.style.display = ''; + this.cardScrollableNode.style.display = ''; } private showEmptySurface(message: string, detail: string): void { - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.emptyContainer.style.display = 'flex'; this.emptyText.textContent = message; this.emptySubtext.textContent = detail; } + private createCardScrollContent(...classNames: string[]): HTMLElement { + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); + content.classList.add(...classNames); + const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( + 'McpListWidget.cardScrollContent', + () => this.cardScrollable.scanDomNode(), + )); + this.cardDisposables.add(resizeObserver.observe(content)); + return content; + } + private addSurfaceActivation(surface: HTMLElement, label: string, callback: () => void, ...classNames: string[]): HTMLButtonElement { const primaryAction = createCustomizationCardPrimaryAction(surface, label, ...classNames); this.firstCardFocusElement ??= primaryAction; @@ -1471,7 +1501,7 @@ export class McpListWidget extends Disposable { DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const content = this.createCardScrollContent(); this.renderFeaturedServers(content); const installedList = this.renderCardSection( @@ -1838,7 +1868,7 @@ export class McpListWidget extends Disposable { this.availableSection = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-search-results')); + const content = this.createCardScrollContent('plugin-search-results'); if (this.installedEntries.length > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-mcp-servers-section', this.installedEntries.length); installedList.classList.add('plugin-inventory-list'); @@ -2003,7 +2033,8 @@ export class McpListWidget extends Disposable { this.lastHeaderHeight = headerHeight; const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); - this.cardContainer.style.height = `${listHeight}px`; + this.cardScrollableNode.style.height = `${listHeight}px`; + this.cardScrollable.scanDomNode(); } /** @@ -2017,16 +2048,14 @@ export class McpListWidget extends Disposable { * Scrolls the list so the last item is visible. */ revealLastItem(): void { - if (this.cardScrollElement) { - this.cardScrollElement.scrollTop = this.cardScrollElement.scrollHeight; - } + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); } /** * Focuses the list. */ focus(): void { - if (this.cardContainer.style.display !== 'none') { + if (this.cardScrollableNode.style.display !== 'none') { this.firstCardFocusElement?.focus(); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 18ab773f1e98d5..8b5104713385df 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -448,7 +448,7 @@ .ai-customization-list-widget .list-container { flex: 1; min-height: 0; - overflow: auto; + overflow: hidden; } .ai-customization-list-widget .list-empty-message { @@ -1336,6 +1336,9 @@ per-word capitalization does not survive translation. */ position: relative; flex: 1; min-height: 0; + width: 100%; + max-width: calc(840px + var(--vscode-spacing-size160)); + margin: 0 auto; } /* The scrolled element: bound it to the wrapper so clientHeight < scrollHeight when content overflows. */ @@ -1346,7 +1349,7 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; gap: var(--vscode-spacing-size240); - padding: 0 max(var(--vscode-spacing-size20), calc((100% - 840px) / 2)) var(--vscode-spacing-size200); + padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; box-sizing: border-box; } @@ -2769,6 +2772,20 @@ per-word capitalization does not survive translation. */ overflow: hidden; } +.plugin-list-widget .plugin-card-scrollable { + position: relative; + flex: 1; + min-height: 0; + width: 100%; + max-width: calc(840px + var(--vscode-spacing-size160)); + margin: 0 auto; +} + +.plugin-list-widget .plugin-card-scrollable > .plugin-card-container { + position: absolute; + inset: 0; +} + .plugin-list-widget .plugin-card-scroll { height: 100%; overflow: auto; @@ -2776,6 +2793,12 @@ per-word capitalization does not survive translation. */ box-sizing: border-box; } +.plugin-list-widget .plugin-card-scroll-content { + height: auto; + overflow: visible; + padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; +} + .plugin-list-widget .plugin-marketplace-back-container { flex-shrink: 0; display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 69a83b62e26505..0885ce840f8b43 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -47,6 +47,8 @@ import { getErrorMessage } from '../../../../../base/common/errors.js'; import { getPluginInclusionLabel } from './aiCustomizationPresentation.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; const $ = DOM.$; @@ -634,6 +636,8 @@ export class PluginListWidget extends Disposable { private searchAndButtonContainer!: HTMLElement; private searchInput!: InputBox; private cardContainer!: HTMLElement; + private cardScrollable!: DomScrollableElement; + private cardScrollableNode!: HTMLElement; private listContainer!: HTMLElement; private list!: WorkbenchList; private emptyContainer!: HTMLElement; @@ -858,8 +862,24 @@ export class PluginListWidget extends Disposable { disabledText.textContent = localize('pluginsDisabledTitle', "Plugins are disabled"); this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); - this.cardContainer = DOM.append(this.element, $('.plugin-card-container')); - this.cardContainer.style.display = 'none'; + this.cardContainer = $('.plugin-card-container'); + this.cardScrollable = this._register(new DomScrollableElement(this.cardContainer, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: false, + })); + this._register(DOM.addDisposableListener(this.cardContainer, DOM.EventType.SCROLL, () => { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollTop }); + })); + this.cardScrollableNode = this.cardScrollable.getDomNode(); + this.cardScrollableNode.classList.add('plugin-card-scrollable'); + this.cardScrollableNode.style.display = 'none'; + this.element.appendChild(this.cardScrollableNode); + const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( + 'PluginListWidget.cardScrollable', + () => this.cardScrollable.scanDomNode(), + )); + this._register(cardResizeObserver.observe(this.cardScrollableNode)); // List container this.listContainer = DOM.append(this.element, $('.mcp-list-container')); @@ -1180,15 +1200,26 @@ export class PluginListWidget extends Disposable { private showCardSurface(): void { this.emptyContainer.style.display = 'none'; this.listContainer.style.display = 'none'; - this.cardContainer.style.display = ''; + this.cardScrollableNode.style.display = ''; } private showEmptySurface(): void { - this.cardContainer.style.display = 'none'; + this.cardScrollableNode.style.display = 'none'; this.listContainer.style.display = 'none'; this.emptyContainer.style.display = 'flex'; } + private createCardScrollContent(...classNames: string[]): HTMLElement { + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); + content.classList.add(...classNames); + const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( + 'PluginListWidget.cardScrollContent', + () => this.cardScrollable.scanDomNode(), + )); + this.cardDisposables.add(resizeObserver.observe(content)); + return content; + } + private addSurfaceActivation(surface: HTMLElement, label: string, callback: () => void, ...classNames: string[]): HTMLButtonElement { const primaryAction = createCustomizationCardPrimaryAction(surface, label, ...classNames); this.rememberCardFocusElement(primaryAction); @@ -1231,7 +1262,7 @@ export class PluginListWidget extends Disposable { DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const content = this.createCardScrollContent(); const installedPlugins = this.installedItems; this.renderDiscoverySnapshot(content); @@ -1587,7 +1618,7 @@ export class PluginListWidget extends Disposable { } this.showCardSurface(); - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const content = this.createCardScrollContent(); const recommendedKeys = this.pluginMarketplaceService.recommendedPlugins.get(); const recommended = marketplaceItems.filter(item => recommendedKeys.has(getMarketplaceRecommendationKey(item))); const allPlugins = marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))); @@ -1905,7 +1936,7 @@ export class PluginListWidget extends Disposable { this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-search-results')); + const content = this.createCardScrollContent('plugin-search-results'); if (installedCount > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-plugins-section', installedCount); installedList.classList.add('plugin-inventory-list'); @@ -2053,9 +2084,10 @@ export class PluginListWidget extends Disposable { const backHeight = this.marketplaceBackContainer.offsetHeight; const listHeight = Math.max(0, height - searchBarHeight - headerHeight - backHeight); - this.cardContainer.style.height = `${listHeight}px`; + this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; this.list.layout(listHeight, width); + this.cardScrollable.scanDomNode(); } focusSearch(): void { @@ -2063,13 +2095,17 @@ export class PluginListWidget extends Disposable { } revealLastItem(): void { + if (this.cardScrollableNode.style.display !== 'none') { + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + return; + } if (this.list.length > 0) { this.list.reveal(this.list.length - 1); } } focus(): void { - if (this.cardContainer.style.display !== 'none') { + if (this.cardScrollableNode.style.display !== 'none') { this.firstCardFocusElement?.focus(); } else if (this.list.length > 0) { this.list.domFocus(); 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 d4ef84286f36e5..bf315b158554ae 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -1170,6 +1170,8 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor if (options.scrollToBottom) { editor.revealLastItem(); + // Allow the 500ms hide delay and 800ms fade transition to complete. + await new Promise(resolve => setTimeout(resolve, 1400)); } if (options.migrationCategory) { @@ -2136,6 +2138,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Scrolled-to-bottom variants — verify last items are fully visible above footer PromptsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, + virtualTime: { durationMs: 1500 }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Prompts, @@ -2145,6 +2148,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, + virtualTime: { durationMs: 1500 }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, @@ -2154,6 +2158,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, + virtualTime: { durationMs: 1500 }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index b3f6d210a38003..0e7c987b6e402e 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -7,16 +7,16 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/c13f82b9dbe63bd2450bb28f48d66704613bfa02a36b47c6a05a3c9b4d0b81ea) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6c26540ad074f1bb302f82ce39f5d1cce3478679bd385cc0e5b68c3b7d48c1c7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e268b39e5d6e416b04ef4d38c0bd5af7d4c2fbd65fccf525cdd39bb4c0a8b677) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a90405bc9ec5b34569c77e3d180f79b847afe6daa4668a72d158b9787cfc55bc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/08799bed239d1dda65f4faf6a038c606dba9c4c24946271440f2c931da42da3d) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d51c5ae97416ae8983026c9436150ba766b2837d8794c113ab5891665df74561) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a83a9d7e75afeb186484f56b4ac280426db67149a9f4678090ed9b9e2a707ea) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e652f2a9309c421bf0a7c805160521ad2837e320fdec9e00393ff981a2eac014) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b84b57c9751c608e2add0c47891dfa443036d3b72e8d8e090b51bd7230f1aaae) #### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5c4c329b3df33c748b8cd46ba7585490b58b2d42fa71c25a44a7f327b344b1b) @@ -25,58 +25,58 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b150bed8ecf034a2ac61bc46081007101d109ed98bc176824e045965dfff94da) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/34ca750e1747a1e89fbeaab3b755d1372ee503e009f4c2a32a4d7205742b3dce) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bfdfa45f49cf147c63733f42db4f09bc28c5ea17c8f56d39a8e02d1855813be5) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/0a8c7e47a3dadf698e121c5bca782d5235ba9cfb205bfa1362307d4fea2e56f6) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/35ef986c0f72d6682a4fe7e690aab7e3d5a93409c0f030494b2fc23928b05644) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/60660b23153246506d9710d0239c15292079b357def9bbc8d7f92b8184c058c4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/381816517a26c12e647f06d53e86100e1c67445bb76ea60ee3dcd981ffeaa41d) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a424b83224188683990741c5a50163601605069c5809ae8e60c01dbee749000) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b8c214bd7418237091cedd35cb8e5abb117e9b0741c8c108000939306595e0ec) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/818485b76cf89c4cd8951e5819820d764dbcb11505aa0c302cfe757b95981acd) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/74532b70cb20bf39b0b56f444c4bf5edd86cce9ba7716f023cf90f39e9654dd8) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9733bdf2e83f6c6442db4d1ced4634a93d8a292d78ae101cf263fea76f64053c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ca8da65e09bb1c6a09530522e70dd51d18ca870fa13f25506d0608a5505f04a2) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/52daa14b7b61fd8ea7a28f0db9acaa65dbd403a85078c03cc31c0c135101c236) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a35248fe00e671dfa1613970c3d5c5c1cd8d898dfee4526f3595367fbb7cca81) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/94410e4c60912cfa7c8e58096d69c1f3e5d6dde2c5eb4ae17c356d29147c1850) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bbeff84bd7f12045e3033d3df8b2f6e2d4feb30f1d02b3f92d38425fe6078b63) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/11be24ce9bdb02eaa809b871ba506d469a745e42f8f11250903ddb6084f721db) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e0b4dd4aafce49b83131fb5fcbc5458052695d5e9382d014edc411464527ca2a) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d451310e9fce1333215b6be7d258c5fc131a624a41198251d18b75e37d83d23e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8ba7ed9a14433d46c8aed5d7a5cc05047d1e9e18c2916be87c02b9a4cd1c73eb) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/84c453d09dae5b3b87205364370e55360812a425dda96123659ac15402c5dcb4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a30e32dae4d629b88694f3b482add3269620ad0ffad0c310056390bab3efb604) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/669e7d7c8d96c2076c8a576fa3a7e5c5cecf1f95671a675162eefad7a5ff3e11) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4353419eb708c0d7ff8fe8a01a8ecb53c231020e7397995f390bca1f172129a0) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e2d667dd5052de6eca13f0c07f7b4dfa768a4499cdb10885c6e3bb75b636324f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a49fc7f166a304ee372ccca3cd6e9e26688b0c80e1ec69fd734361b0221eee16) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b1001378286b10e85c5ea0d38a2a39b781a2370e07f686182ac0f7f448f7c0b3) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9fcc38b4e24fdfa58c30408dbdaf6d0054cc173dcf9e123d23112149be4f6ea1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e2d667dd5052de6eca13f0c07f7b4dfa768a4499cdb10885c6e3bb75b636324f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a49fc7f166a304ee372ccca3cd6e9e26688b0c80e1ec69fd734361b0221eee16) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b1001378286b10e85c5ea0d38a2a39b781a2370e07f686182ac0f7f448f7c0b3) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9fcc38b4e24fdfa58c30408dbdaf6d0054cc173dcf9e123d23112149be4f6ea1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/71c963b2ea1414b83ed3ad4da0c556197369d23d7f0b63806d0e2bf9f9b04127) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/498dd59bd46769e3ca7393f202f587789c17a2979b87ca378f20e37f333bdfd4) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/683eab172739819c9bac2d664ded814f40824de289ca4c3570b6d968bb04070c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f08f056b5a9843fc413b1105d5ede83a19961aa8d000158779068b94c72135d5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/1d6f85fdd57fe38f105b261d64f2b3f54acd3cadf29d05ed216885dc7aadf30d) @@ -85,10 +85,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a8929c0724d59231dc954fda808ef68226a5e78f09b66ea279e6150c3c39650c) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/1e100b00beb07383f488c73fcc79c855b4098ae24e46db01072f2be1ea954d88) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/afe68d55d548d3234536e2e69147f8c48de5dac0e1eb09b1ff656337c370d664) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/43638fae9595c059725b43c1dd8db047e9f521c1f32e8bb06a7768c6d451ab36) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4f4bf2ba517c50d456a557fb27ff423cce77e24adf626754af2cb055dd931761) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b6806a0c2bed06ad703fdc1f42eb2ca50d8cc3027d9878f05905b276999f3568) From ba0c096166a93c2dc193c3e400b0aa6ef06b1773 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:34:54 +0200 Subject: [PATCH 37/41] sessions: refine Agent Merge PR actions (#333835) * sessions: refine Agent Merge PR actions Add a combined create-PR-and-enable-Agent-Merge operation, reorganize Agent Merge configuration under a submenu, and restore immediate running feedback for changeset operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: register Agent Host configuration service Keep the strict contribution activation harness aligned with the pull request operation contribution's dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address Agent Merge review feedback Reset stale controller state when enabling Agent Merge for a newly created pull request and preserve the delegated toggle command identity in the primary button. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostPullRequestOperationHandler.ts | 41 +++++++++-- .../agentHostPullRequestOperationProvider.ts | 38 ++++++++-- .../test/node/agentHostContributions.test.ts | 8 +- ...entHostPullRequestOperationHandler.test.ts | 57 ++++++++++++++- ...ntHostPullRequestOperationProvider.test.ts | 19 ++++- src/vs/sessions/browser/menus.ts | 7 +- .../contrib/changes/browser/changesView.ts | 29 ++++---- .../sessionsChangesAccessibilityHelp.ts | 2 +- .../test/browser/changesViewActions.test.ts | 5 +- .../browser/agentHostSessionChangesets.ts | 45 +++++++++--- .../agentHost/browser/agentMergeActions.ts | 43 ++++++----- .../agentHostSessionChangesets.test.ts | 73 ++++++++++++++++++- .../test/browser/agentMergeActions.test.ts | 46 +++++++++++- 13 files changed, 346 insertions(+), 67 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 3dbfca1d1c7426..7685d9fea8c38a 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -20,6 +20,8 @@ import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/co import { buildConversationContext } from '../common/agentHostConversationContext.js'; import { IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { readAgentMergeSessionState } from '../common/agentMerge.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; /** * Soft upper bound, in characters, for the conversation context fed to the @@ -43,9 +45,9 @@ export interface PullRequestCreatedEvent { } /** - * Server-side handler for the `create-pr` and `create-draft-pr` changeset - * operations advertised on git-backed sessions whose working directory has - * a GitHub remote. Operation availability is recomputed by + * Server-side handler for pull request creation changeset operations advertised + * on git-backed sessions whose working directory has a GitHub remote. + * Operation availability is recomputed by * `AgentHostChangesetOperationService.updateOperations`. * * The flow mirrors the Copilot CLI extension's `createPullRequest` helper @@ -69,10 +71,12 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation public static readonly OPERATION_CREATE_PR_AUTO_MERGE = 'create-pr-auto-merge'; public static readonly OPERATION_CREATE_PR_AUTO_SQUASH = 'create-pr-auto-squash'; public static readonly OPERATION_CREATE_PR_AUTO_REBASE = 'create-pr-auto-rebase'; + public static readonly OPERATION_CREATE_PR_AGENT_MERGE = 'create-pr-agent-merge'; constructor( private readonly _draft: boolean, private readonly _autoMergeMethod: AutoMergeMethod | undefined, + private readonly _enableAgentMerge: boolean, private readonly _getSessionState: (sessionKey: string) => ISessionWithDefaultChat | undefined, private readonly _resolveBaseBranchName: (sessionKey: string) => Promise, private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void, @@ -82,6 +86,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @IAgentBranchNameGenerator private readonly _branchNameGenerator: IAgentBranchNameGenerator, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @ILogService private readonly _logService: ILogService, ) { } @@ -270,9 +275,8 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation /** * Notifies listeners that the pull request now exists, optionally enables - * auto-merge with the configured {@link AutoMergeMethod} (best-effort: a - * failure to enable auto-merge does not fail the operation), and builds the - * result message describing what happened. + * auto-merge or Agent Merge, and builds the result message describing what + * happened. A failure to enable auto-merge does not fail the operation. */ private async _finalize( pr: CreatedPullRequest, @@ -287,7 +291,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation ): Promise { if (!this._autoMergeMethod) { // No auto-merge configured - this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url, branchName }); + this._completePullRequestOperation(sessionUri, pr.url, branchName); return this._createResult(pr, this._buildMessage(pr, isExisting, 'none', undefined)); } @@ -310,11 +314,32 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation this._logService.warn(`[AgentHostPullRequestOperationHandler] Cannot enable auto-merge for ${owner}/${repo}#${pr.number}: missing pull request node id`); } - this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url, branchName }); + this._completePullRequestOperation(sessionUri, pr.url, branchName); return this._createResult(pr, this._buildMessage(pr, isExisting, autoMergeOutcome, autoMergeError)); } + private _completePullRequestOperation(sessionUri: string, pullRequestUrl: string, branchName: string): void { + this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl, branchName }); + if (!this._enableAgentMerge) { + return; + } + const current = readAgentMergeSessionState(this._configurationService.getSessionConfigValues(sessionUri)); + this._configurationService.updateSessionConfig(sessionUri, { + [SessionConfigKey.AgentMerge]: { + enabled: true, + ...(current?.overrides ? { overrides: current.overrides } : {}), + }, + [SessionConfigKey.AgentMergeController]: {}, + }); + } + private _buildMessage(pr: CreatedPullRequest, isExisting: boolean, autoMergeOutcome: 'none' | 'enabled' | 'failed', autoMergeError: string | undefined): string { + if (this._enableAgentMerge) { + return isExisting + ? localize('agentHost.changeset.pr.existing.agentMerge', "Pull request [#{0}]({1}) already exists; enabled Agent Merge.", pr.number, pr.url) + : localize('agentHost.changeset.pr.created.agentMerge', "Created pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url); + } + let mergeMethodLabel: string | undefined; switch (this._autoMergeMethod) { case 'SQUASH': diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index 303f73c6335840..8e4ee53a0880ff 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -15,6 +15,8 @@ import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } fr import { AgentHostPullRequestLifecycleOperationHandler } from './agentHostPullRequestLifecycleOperationHandler.js'; import { IAgentHostPullRequestStatusService } from './agentHostPullRequestStatusService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { AgentMergeConfigKey, agentMergeRootConfigSchema } from '../common/agentMerge.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { @@ -28,6 +30,7 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, @IAgentHostPullRequestStatusService private readonly _pullRequestStatusService: IAgentHostPullRequestStatusService, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @ILogService private readonly _logService: ILogService, ) { super(); @@ -40,16 +43,18 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); const resolveBaseBranchName = (sessionKey: string) => this._gitStateService.resolveSessionBaseBranchName(sessionKey); const onCreated = (event: PullRequestCreatedEvent) => this._onPullRequestCreated(event); - const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, getSessionState, resolveBaseBranchName, onCreated); - const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, undefined, getSessionState, resolveBaseBranchName, onCreated); - const createAutoMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'MERGE', getSessionState, resolveBaseBranchName, onCreated); - const createAutoSquashPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'SQUASH', getSessionState, resolveBaseBranchName, onCreated); - const createAutoRebasePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'REBASE', getSessionState, resolveBaseBranchName, onCreated); + const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, false, getSessionState, resolveBaseBranchName, onCreated); + const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, undefined, false, getSessionState, resolveBaseBranchName, onCreated); + const createAutoMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'MERGE', false, getSessionState, resolveBaseBranchName, onCreated); + const createAutoSquashPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'SQUASH', false, getSessionState, resolveBaseBranchName, onCreated); + const createAutoRebasePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'REBASE', false, getSessionState, resolveBaseBranchName, onCreated); + const createAgentMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, true, getSessionState, resolveBaseBranchName, onCreated); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_MERGE, createAutoMergePrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_SQUASH, createAutoSquashPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_REBASE, createAutoRebasePrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, createAgentMergePrHandler)); for (const [operationId, action] of [ [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, 'mark-ready'], @@ -61,6 +66,17 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem } store.add(this._pullRequestStatusService.onDidChangePullRequestStatus(sessionKey => registry.onDidChangeOperations(sessionKey))); + let agentMergeEnabled = this._isAgentMergeEnabled(); + store.add(this._configurationService.onDidRootConfigChange(() => { + const nextAgentMergeEnabled = this._isAgentMergeEnabled(); + if (agentMergeEnabled === nextAgentMergeEnabled) { + return; + } + agentMergeEnabled = nextAgentMergeEnabled; + for (const sessionKey of this._stateManager.getSessionUris()) { + registry.onDidChangeOperations(sessionKey); + } + })); store.add({ dispose: () => { this._registry = undefined; } }); return store; } @@ -128,6 +144,14 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle, }, + ...(this._isAgentMergeEnabled() ? [{ + id: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, + label: localize('agentHost.changeset.createPRAgentMerge', "Create PR & Enable Agent Merge"), + icon: 'git-merge', + group: 'pull-request', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }] : []), { id: 'create-draft-pr', label: localize('agentHost.changeset.createDraftPR', "Create Draft PR"), @@ -138,6 +162,10 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem }] satisfies ChangesetOperation[]; } + private _isAgentMergeEnabled(): boolean { + return this._configurationService.getRootValue(agentMergeRootConfigSchema, AgentMergeConfigKey.Enabled) === true; + } + /** * Operations for a branch that already has a pull request. * diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts index a88d4f16ea762e..a56032dc098d7a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -15,6 +15,7 @@ import { IAgentHostGitStateService } from '../../common/agentHostGitStateService import { IAgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; import { activateAgentHostContributions } from '../../node/agentHostContributions.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; class FailingChangesetOperationService extends Disposable implements IAgentHostChangesetOperationService { declare readonly _serviceBrand: undefined; @@ -64,12 +65,15 @@ suite('AgentHostContributions', () => { test('disposes earlier registrations when activation fails', () => { const changesetOperationService = disposables.add(new FailingChangesetOperationService()); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); const services = new ServiceCollection( - [IAgentHostStateManager, disposables.add(new AgentHostStateManager(new NullLogService()))], + [IAgentHostStateManager, stateManager], [IAgentHostChangesetOperationService, changesetOperationService], [IAgentHostGitStateService, nullGitStateService], [IAgentHostPullRequestStatusService, nullPullRequestStatusService], - [ILogService, new NullLogService()], + [IAgentConfigurationService, disposables.add(new AgentConfigurationService(stateManager, logService))], + [ILogService, logService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 425d57871edd82..0441df9a391c1f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -24,6 +24,9 @@ import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; import type { IAgentBranchNameGenerator, IAgentBranchNameGeneratorRequest } from '../../node/shared/agentBranchNameGenerator.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import type { AgentMergeControllerState, AgentMergeSessionOverrides } from '../../common/agentMerge.js'; +import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; class TestCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; @@ -206,11 +209,12 @@ function createAuthenticationService(withCopilotToken = false): IAgentHostAuthen }; } -function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; baseBranch?: string; branchPrefix?: string }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; createdBranches: string[]; copilotApiService: TestCopilotApiService; branchNameGenerator: TestBranchNameGenerator } { +function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; enableAgentMerge?: boolean; agentMergeOverrides?: AgentMergeSessionOverrides; agentMergeControllerState?: AgentMergeControllerState; baseBranch?: string; branchPrefix?: string }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; createdBranches: string[]; sessionConfigUpdates: Record[]; copilotApiService: TestCopilotApiService; branchNameGenerator: TestBranchNameGenerator } { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agent:/session'); const createdEvents: string[] = []; const createdBranches: string[] = []; + const sessionConfigUpdates: Record[] = []; stateManager.createSession({ resource: session.toString(), provider: 'copilot', @@ -244,10 +248,22 @@ function setup(disposables: Pick, gitService: TestGitSer stateManager.setSessionMeta(session.toString(), sessionMeta); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); const branchNameGenerator = new TestBranchNameGenerator(); + const configurationService = new class extends mock() { + override getSessionConfigValues(): Record { + return { + ...(options?.agentMergeOverrides ? { [SessionConfigKey.AgentMerge]: { enabled: false, overrides: options.agentMergeOverrides } } : {}), + ...(options?.agentMergeControllerState ? { [SessionConfigKey.AgentMergeController]: options.agentMergeControllerState } : {}), + }; + } + override updateSessionConfig(_session: string, patch: Record): void { + sessionConfigUpdates.push(patch); + } + }(); return { handler: new AgentHostPullRequestOperationHandler( options?.draft ?? false, options?.autoMergeMethod, + options?.enableAgentMerge ?? false, sessionKey => { const state = stateManager.getSessionState(sessionKey); if (state && options?.turns) { @@ -260,10 +276,11 @@ function setup(disposables: Pick, gitService: TestGitSer createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`); createdBranches.push(event.branchName); }, - createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, branchNameGenerator, new NullLogService()), + createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, branchNameGenerator, configurationService, new NullLogService()), session, createdEvents, createdBranches, + sessionConfigUpdates, copilotApiService, branchNameGenerator, }; @@ -309,6 +326,42 @@ suite('AgentHostPullRequestOperationHandler', () => { }); }); + test('enables Agent Merge after creating the pull request, preserves overrides, and clears stale controller state', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const overrides: AgentMergeSessionOverrides = { fixCI: false }; + const { handler, session, createdEvents, sessionConfigUpdates } = setup(disposables, gitService, octoKitService, { + enableAgentMerge: true, + agentMergeOverrides: overrides, + agentMergeControllerState: { + target: { + branchName: 'previous-branch', + pullRequestUrl: 'https://github.com/microsoft/vscode/pull/1', + enabledAt: new Date(1).toISOString(), + commentWatermark: '', + }, + }, + }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + createdEvents, + sessionConfigUpdates, + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123) and enabled Agent Merge.' }, + createdEvents: ['agent:/session:https://github.com/microsoft/vscode/pull/123'], + sessionConfigUpdates: [{ + [SessionConfigKey.AgentMerge]: { + enabled: true, + overrides, + }, + [SessionConfigKey.AgentMergeController]: {}, + }], + }); + }); + test('creates a generated branch before committing when the current branch is the base branch', async () => { const gitService = new TestGitService(); gitService.uncommitted = true; diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index a96e11196de22b..d25bea09e54de8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -15,6 +15,9 @@ import { SessionStatus, type ISessionGitHubState, type ISessionGitState } from ' import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { ChangesetKind } from '../../common/changesetUri.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentMergeConfigKey } from '../../common/agentMerge.js'; const nullGitStateService = new class implements IAgentHostGitStateService { declare readonly _serviceBrand: undefined; @@ -70,7 +73,7 @@ const pullRequestForBranch: ISessionGitHubState = { suite('AgentHostPullRequestOperationContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None): AgentHostPullRequestOperationContribution { + function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None, agentMergeEnabled = false): AgentHostPullRequestOperationContribution { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); if (isolation) { stateManager.createSession({ @@ -87,11 +90,17 @@ suite('AgentHostPullRequestOperationContribution', () => { values: { [SessionConfigKey.Isolation]: isolation }, }); } + const configurationService = new class extends mock() { + override getRootValue(_schema: never, key: string) { + return (key === AgentMergeConfigKey.Enabled ? agentMergeEnabled : undefined) as never; + } + }(); return disposables.add(new AgentHostPullRequestOperationContribution( stateManager, disposables.add(new InstantiationService()), nullGitStateService, createStatusService(status, onDidChangePullRequestStatus), + configurationService, new NullLogService(), )); } @@ -104,6 +113,14 @@ suite('AgentHostPullRequestOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-draft-pr']); }); + test('advertises Create PR and Enable Agent Merge as the last Create PR option when Agent Merge is enabled', () => { + const provider = createContribution(undefined, undefined, Event.None, true); + + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); + + assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-pr-agent-merge', 'create-draft-pr']); + }); + test('does not advertise PR operations for folder sessions with outgoing changes', () => { const provider = createContribution(undefined, 'folder'); diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 87ab0ab0413155..cd278d7e84e11a 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -67,11 +67,14 @@ export const Menus = { /** * Entries merged into the dropdown of the changes button bar's primary * button. A submenu contributed to its `primary` group names a group of - * related actions and takes over the button when it applies. + * related actions, takes over the button when it applies, and uses its first + * entry as the primary invocation. */ ChangesOperationsDropdown: new MenuId('SessionsChangesOperationsDropdown'), - /** Agent Merge's own entries, opened as a context menu from its button. */ + /** Agent Merge entries whose first visible action is invoked by its primary button. */ ChangesAgentMerge: new MenuId('SessionsChangesAgentMerge'), + /** Per-session Agent Merge configuration. */ + ChangesAgentMergeConfigure: new MenuId('SessionsChangesAgentMergeConfigure'), /** Choices for when Agent Merge may merge the pull request. */ ChangesAgentMergeMergePullRequest: new MenuId('SessionsChangesAgentMergeMergePullRequest'), diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 138b6c155e0c0a..072b01cfbf5b57 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -112,6 +112,7 @@ const CHAT_PET_CREATE_PULL_REQUEST_ACTION_IDS = new Set([ 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', + 'create-pr-agent-merge', 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', 'workbench.action.agentSessions.runSkill.createPR', ]); @@ -311,7 +312,6 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @IChatPetService chatPetService: IChatPetService, - @IContextMenuService contextMenuService: IContextMenuService, @ILogService private readonly logService: ILogService, ) { super(); @@ -323,6 +323,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt // config provider below, which `buttonBar.update` calls synchronously // from the same autorun that computes it. let primaryIsBusy = false; + let primaryCustomLabel: string | undefined; const buttonBar = this._buttonBar = this._register(instantiationService.createInstance( WorkbenchButtonBar, @@ -332,7 +333,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt renderSecondaryActions: false, buttonConfigProvider: (action, index) => { return index === 0 - ? { showIcon: true, showLabel: true, customLabel: stripIcons(action.label), showSpinner: primaryIsBusy } + ? { showIcon: true, showLabel: true, customLabel: primaryCustomLabel ?? stripIcons(action.label), showSpinner: primaryIsBusy } : { showIcon: true, showLabel: false }; } } @@ -352,29 +353,28 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt // there takes over the primary button when it applies, which is how // Agent Merge can own the button without the widget knowing about it. // - // A submenu contributed to that group names a group of related actions - // rather than being an action itself, so clicking the button opens just - // those actions as a context menu — the button's own dropdown carries - // unrelated operations too. + // A submenu contributed to that group names related actions. Its first + // entry is the primary invocation; the button's dropdown carries the + // remaining entries together with unrelated operations. const dropdownMenuActionsObs = observableFromEvent(dropdownMenu.onDidChange, () => { const groups = dropdownMenu.getActions({ shouldForwardArgs: true }); const primaryGroup = groups.find(([group]) => group === CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP)?.[1] ?? []; const rest = groups.filter(([group]) => group !== CHANGES_OPERATIONS_DROPDOWN_PRIMARY_GROUP).map(([, actions]) => actions); const contributed = primaryGroup[0]; - const primary = contributed instanceof SubmenuItemAction + const delegated = contributed instanceof SubmenuItemAction ? contributed.actions[0] : undefined; + const primary = contributed instanceof SubmenuItemAction && delegated ? toAction({ - id: contributed.item.submenu.id, - label: contributed.label, + id: delegated.id, + label: delegated.label, + tooltip: delegated.tooltip, + enabled: delegated.enabled, // Wrapping the submenu in a plain action would drop the icon // its menu item declared, so it is carried over the way any // action carries one. class: ThemeIcon.isThemeIcon(contributed.item.icon) ? ThemeIcon.asClassName(contributed.item.icon) : undefined, - run: () => contextMenuService.showContextMenu({ - getAnchor: () => buttonBar.buttons[0]?.element ?? container, - getActions: () => contributed.actions, - }), + run: () => delegated.run(), }) - : contributed; + : contributed instanceof SubmenuItemAction ? undefined : contributed; return { primary, contributed, isAgentMerge: contributed instanceof SubmenuItemAction && contributed.item.submenu === Menus.ChangesAgentMerge, groups: primaryGroup.length > 0 ? [primaryGroup, ...rest] : rest }; }); @@ -494,6 +494,7 @@ class ChangesWorkbenchButtonBarWidget extends Disposable implements IChangesButt primaryIsBusy = usesContributedPrimary ? dropdownMenuActions.isAgentMerge && agentMergeEnabledObs.read(reader) : operations.hasRunning; + primaryCustomLabel = usesContributedPrimary ? stripIcons(dropdownMenuActions.contributed?.label ?? primaryAction?.label ?? '') : undefined; buttonBar.update(primaryActions, menuActions.secondary); this._logButtonBar(primaryAction, usesContributedPrimary, operations.hasRunning, primaryIsBusy, groups, menuActions.primary); diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts index 4cb51af3c79500..0edea0d059055f 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -34,7 +34,7 @@ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplemen content.push(localize('sessionsChanges.tree', "Use the up and down arrow keys to move between changed files, and the left and right arrow keys to collapse or expand folders. Press Enter to open the selected file's diff.")); content.push(localize('sessionsChanges.checks', "The Checks section lists the continuous integration checks for the session's pull request. Its header is a button: press Enter or Space to collapse or expand it{0}.", '')); content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); - content.push(localize('sessionsChanges.operations', "When available, the Changes toolbar or editor title bar also provides actions to commit, merge, sync, or create a pull request. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); + content.push(localize('sessionsChanges.operations', "When available, the Changes toolbar or editor title bar also provides actions to commit, merge, sync, or create a pull request. When Agent Merge is the primary action, activate it to toggle Agent Merge and use its dropdown to configure it. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); content.push(layoutService.isSinglePaneLayoutEnabled ? localize('sessionsChanges.diffView.singlePane', "File diffs can prefer side-by-side or inline layout. Unless screen reader optimized mode is enabled, side-by-side diffs automatically use inline layout when space is limited. Use Always Show Inline Diff in the editor title bar's More Actions menu, or use the Toggle Preferred Diff View command to switch the preference{0}.", '') : localize('sessionsChanges.diffView.classic', "File diffs can use side-by-side or inline layout. Use Inline View in the editor title area's More Actions menu, or use the Toggle Inline View command to switch the layout{0}.", '')); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index 8b2c81ee766370..8687a09cd6ca75 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -95,6 +95,7 @@ suite('Changes View Actions', () => { 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', + 'create-pr-agent-merge', 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', 'workbench.action.agentSessions.runSkill.createPR', 'create-draft-pr', @@ -103,8 +104,8 @@ suite('Changes View Actions', () => { ].map(actionId => unlockChatPetCreatePullRequestAchievement(actionId, chatPetService)); assert.deepStrictEqual({ results, attemptedUnlocks }, { - results: [true, true, true, true, true, true, false, false, false], - attemptedUnlocks: Array(6).fill(ChatPetAchievementIds.CreatePullRequest), + results: [true, true, true, true, true, true, true, false, false, false], + attemptedUnlocks: Array(7).fill(ChatPetAchievementIds.CreatePullRequest), }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 49e428b561557f..138c3a1f56d912 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -232,6 +232,8 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { readonly capabilities: ISessionChangesetCapabilities; + private readonly _locallyRunningOperationCounts = observableValue>(this, new Map()); + protected abstract readonly channelUriObs: IObservable; protected abstract readonly changesetStateObs: IObservable>; private readonly _changesetFilesObs: IObservable; @@ -338,7 +340,10 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { }); this.operations = derivedOpts({ equalsFn: arrayEqualsC(structuralEquals) }, reader => { - return operationsObs.read(reader) ?? []; + const locallyRunningOperationCounts = this._locallyRunningOperationCounts.read(reader); + return operationsObs.read(reader).map(operation => locallyRunningOperationCounts.has(operation.id) && operation.status !== SessionChangesetOperationStatus.Running + ? { ...operation, status: SessionChangesetOperationStatus.Running } + : operation); }); } @@ -381,16 +386,34 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { } } - await connection.invokeChangesetOperation({ - operationId, - channel: channel.toString(), - target: target?.kind === 'resource' - ? { - kind: ChangesetOperationTargetKind.Resource, - resource: target.resource.toString() - } - : undefined, - }); + this._setOperationLocallyRunning(operationId, true); + try { + await connection.invokeChangesetOperation({ + operationId, + channel: channel.toString(), + target: target?.kind === 'resource' + ? { + kind: ChangesetOperationTargetKind.Resource, + resource: target.resource.toString() + } + : undefined, + }); + } finally { + this._setOperationLocallyRunning(operationId, false); + } + } + + private _setOperationLocallyRunning(operationId: string, running: boolean): void { + const counts = new Map(this._locallyRunningOperationCounts.get()); + const count = counts.get(operationId) ?? 0; + if (running) { + counts.set(operationId, count + 1); + } else if (count <= 1) { + counts.delete(operationId); + } else { + counts.set(operationId, count - 1); + } + this._locallyRunningOperationCounts.set(counts, undefined); } setReviewState(resources: readonly URI[], reviewed: boolean): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts index 74b15e9da83e6b..4ae3c9d7f6e224 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts @@ -208,8 +208,8 @@ abstract class AgentMergeActionBase extends Action2 { } // The primary button only names the Agent Merge actions, so it is contributed -// as a submenu: the changes button bar opens exactly these entries as a context -// menu rather than its own dropdown, which also carries pull request operations. +// as a submenu: its first entry is the primary invocation, while the button's +// dropdown also carries the remaining pull request operations. // // Deliberately not gated on Agent Merge being enabled for the session: the // button stands in for the auto-merge operations either way, and enabling it is @@ -225,8 +225,8 @@ MenuRegistry.appendMenuItem(Menus.ChangesOperationsDropdown, { when: ContextKeyExpr.and(agentMergeMenuPrecondition, agentMergeOwnsPrimaryButton), }); -/** Menus the Agent Merge entries appear on: the operations dropdown, and their own context menu. */ -const agentMergeMenus = [Menus.ChangesOperationsDropdown, Menus.ChangesAgentMerge]; +/** Menus the top-level Agent Merge entries appear on: the operations dropdown, and their own context menu. */ +const agentMergeTopLevelMenus = [Menus.ChangesOperationsDropdown, Menus.ChangesAgentMerge]; registerAction2(class EnableAgentMergeInSessionAction extends AgentMergeActionBase { constructor() { @@ -236,7 +236,7 @@ registerAction2(class EnableAgentMergeInSessionAction extends AgentMergeActionBa // goes, so a check mark next to "Enable" would only be ambiguous. title: localize2('agentMerge.enableInSession', "Enable Agent Merge"), f1: false, - menu: agentMergeMenus.map(id => ({ + menu: agentMergeTopLevelMenus.map(id => ({ id, group: '1_agentMerge', order: 1, @@ -262,7 +262,7 @@ registerAction2(class DisableAgentMergeInSessionAction extends AgentMergeActionB id: 'sessions.agentHost.agentMerge.disableInSession', title: localize2('agentMerge.disableInSession', "Disable Agent Merge"), f1: false, - menu: agentMergeMenus.map(id => ({ + menu: agentMergeTopLevelMenus.map(id => ({ id, group: '1_agentMerge', order: 1, @@ -282,6 +282,16 @@ registerAction2(class DisableAgentMergeInSessionAction extends AgentMergeActionB } }); +for (const id of agentMergeTopLevelMenus) { + MenuRegistry.appendMenuItem(id, { + submenu: Menus.ChangesAgentMergeConfigure, + title: localize2('agentMerge.configure.submenu', "Configure Agent Merge"), + group: '1_agentMerge', + order: 2, + when: agentMergeMenuPrecondition, + }); +} + for (const [index, action] of agentMergeRepairActions.entries()) { registerAction2(class ToggleAgentMergeRepairAction extends AgentMergeActionBase { constructor() { @@ -290,12 +300,12 @@ for (const [index, action] of agentMergeRepairActions.entries()) { title: { value: agentMergeActionLabels[action], original: agentMergeActionLabels[action] }, toggled: AgentMergeSessionActionContexts[action], f1: false, - menu: agentMergeMenus.map(id => ({ - id, - group: '2_agentMergeActions', + menu: [{ + id: Menus.ChangesAgentMergeConfigure, + group: '1_agentMergeActions', order: index, when: agentMergeMenuPrecondition, - })), + }], }); } @@ -314,8 +324,7 @@ for (const [index, action] of agentMergeRepairActions.entries()) { // One submenu entry per value, so the title can name the current choice without // the user having to open it. Exactly one is ever visible. for (const value of agentMergeMergePullRequestValues) { - MenuRegistry.appendMenuItem(Menus.ChangesOperationsDropdown, mergePullRequestSubmenuItem(value)); - MenuRegistry.appendMenuItem(Menus.ChangesAgentMerge, mergePullRequestSubmenuItem(value)); + MenuRegistry.appendMenuItem(Menus.ChangesAgentMergeConfigure, mergePullRequestSubmenuItem(value)); } function mergePullRequestSubmenuItem(value: AgentMergeMergePullRequest): ISubmenuItem { @@ -323,7 +332,7 @@ function mergePullRequestSubmenuItem(value: AgentMergeMergePullRequest): ISubmen return { submenu: Menus.ChangesAgentMergeMergePullRequest, title: { value: title, original: title }, - group: '2_agentMergeActions', + group: '1_agentMergeActions', order: agentMergeRepairActions.length, when: ContextKeyExpr.and(agentMergeMenuPrecondition, AgentMergeSessionMergePullRequestContext.isEqualTo(value)), }; @@ -358,12 +367,12 @@ registerAction2(class OpenAgentMergeDefaultsAction extends Action2 { id: 'sessions.agentHost.agentMerge.openDefaults', title: localize2('agentMerge.openDefaults', "Agent Merge Defaults"), f1: false, - menu: agentMergeMenus.map(id => ({ - id, - group: '3_agentMergeDefaults', + menu: [{ + id: Menus.ChangesAgentMergeConfigure, + group: '2_agentMergeDefaults', order: 1, when: agentMergeMenuPrecondition, - })), + }], }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts index cbd6bcfb5095de..25d7c6fdf1cc52 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts @@ -4,16 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { IReference } from '../../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../../base/common/observable.js'; import { isLinux } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import type { InvokeChangesetOperationResult } from '../../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; +import { ChangesetOperationScope, ChangesetOperationStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ChangesetStatus, StateComponents, type ChangesetState, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IChatSessionFileChange2 } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISessionFileChange } from '../../../../../services/sessions/common/session.js'; +import { ISessionFileChange, SessionChangesetOperationStatus } from '../../../../../services/sessions/common/session.js'; import { createChangesets, filterChangesToPrimaryWorkingDirectory, IAgentHostChangeset } from '../../browser/agentHostSessionChangesets.js'; import { IAgentHostAdapterOptions } from '../../browser/baseAgentHostSessionsProvider.js'; @@ -222,4 +231,66 @@ suite('AgentHostSessionChangesets', () => { ['uncommitted*']); }); }); + + test('marks an invoked operation running locally until the host request settles', async () => { + const operationId = 'create-pr-auto-merge'; + const operationResult = new DeferredPromise(); + const changesetState: ChangesetState = { + status: ChangesetStatus.Ready, + files: [], + operations: [{ + id: operationId, + label: 'Create PR (Auto-Merge)', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }], + }; + const connection = new class extends mock() { + override getSubscription(): IReference> { + const subscription = new class extends mock>() { + override readonly value = changesetState as ComponentToState[T]; + override readonly verifiedValue = changesetState as ComponentToState[T]; + override readonly onDidChange = Event.None; + override readonly onWillApplyAction = Event.None; + override readonly onDidApplyAction = Event.None; + }(); + return { + object: subscription, + dispose: () => { }, + }; + } + + override invokeChangesetOperation(): Promise { + return operationResult.p; + } + }(); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) }); + const options: IAgentHostAdapterOptions = { + icon: Codicon.copilot, + loading: constObservable(false), + buildWorkspace: () => undefined, + instantiationService, + getConnection: () => connection, + agentCapabilities: constObservable(undefined), + mapBackendSessionResource: resource => resource, + }; + const changeset = createChangesets( + URI.parse('ahp-session:/session-1'), + options, + constObservable(true), + [{ label: 'Session Changes', changeKind: ChangesetKind.Session, uriTemplate: 'changeset:/session-1' }], + )[0]; + + const invocation = changeset.invokeOperation(operationId); + const whileRunning = changeset.operations.get().map(operation => ({ id: operation.id, status: operation.status })); + operationResult.complete({}); + await invocation; + const afterCompletion = changeset.operations.get().map(operation => ({ id: operation.id, status: operation.status })); + + assert.deepStrictEqual({ whileRunning, afterCompletion }, { + whileRunning: [{ id: operationId, status: SessionChangesetOperationStatus.Running }], + afterCompletion: [{ id: operationId, status: SessionChangesetOperationStatus.Idle }], + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts index 09cdfebe927c85..121cf5307e9e7a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { isIMenuItem, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; +import { isIMenuItem, isISubmenuItem, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; import { Context } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; import { AgentHostPullRequestOperationId } from '../../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { AgentMergeSettingId } from '../../../../../../platform/agentHost/common/agentMerge.js'; @@ -104,4 +104,48 @@ suite('Agent Merge Actions', () => { disableWhileOn: true, }); }); + + test('the Agent Merge primary menu places its toggle before the configuration submenu', () => { + const visibleEntries = (menu: typeof Menus.ChangesAgentMerge, agentMergeEnabled: boolean) => MenuRegistry.getMenuItems(menu) + .filter(entry => entry.group === '1_agentMerge') + .filter(entry => entry.when?.evaluate(createContext({ primaryOperation: AgentHostPullRequestOperationId.EnableAutoMerge, agentMergeEnabled })) ?? true) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(entry => isIMenuItem(entry) ? entry.command.id : entry.submenu.id); + + assert.deepStrictEqual({ + operationsWhileOff: visibleEntries(Menus.ChangesOperationsDropdown, false), + operationsWhileOn: visibleEntries(Menus.ChangesOperationsDropdown, true), + primaryWhileOff: visibleEntries(Menus.ChangesAgentMerge, false), + primaryWhileOn: visibleEntries(Menus.ChangesAgentMerge, true), + }, { + operationsWhileOff: ['sessions.agentHost.agentMerge.enableInSession', Menus.ChangesAgentMergeConfigure.id], + operationsWhileOn: ['sessions.agentHost.agentMerge.disableInSession', Menus.ChangesAgentMergeConfigure.id], + primaryWhileOff: ['sessions.agentHost.agentMerge.enableInSession', Menus.ChangesAgentMergeConfigure.id], + primaryWhileOn: ['sessions.agentHost.agentMerge.disableInSession', Menus.ChangesAgentMergeConfigure.id], + }); + }); + + test('all Agent Merge configuration entries are nested under Configure Agent Merge', () => { + const topLevelConfigurationCommands = [Menus.ChangesOperationsDropdown, Menus.ChangesAgentMerge] + .flatMap(menu => MenuRegistry.getMenuItems(menu)) + .filter(isIMenuItem) + .map(item => item.command.id) + .filter(id => id.startsWith('sessions.agentHost.agentMerge.toggle.') || id === 'sessions.agentHost.agentMerge.openDefaults'); + const configurationItems = MenuRegistry.getMenuItems(Menus.ChangesAgentMergeConfigure); + + assert.deepStrictEqual({ + topLevelConfigurationCommands, + commands: configurationItems.filter(isIMenuItem).map(item => item.command.id), + submenus: configurationItems.filter(isISubmenuItem).map(item => item.submenu.id), + }, { + topLevelConfigurationCommands: [], + commands: [ + 'sessions.agentHost.agentMerge.toggle.addressReviews', + 'sessions.agentHost.agentMerge.toggle.fixCI', + 'sessions.agentHost.agentMerge.toggle.resolveConflicts', + 'sessions.agentHost.agentMerge.openDefaults', + ], + submenus: Array(3).fill(Menus.ChangesAgentMergeMergePullRequest.id), + }); + }); }); From 54157b1802d6a22f56669bc3372129a2494c1be9 Mon Sep 17 00:00:00 2001 From: Paul <8560030+pwang347@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:59:36 -0500 Subject: [PATCH 38/41] Fix Automation skill completion interactions (#333834) * Fix skill completions in automations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24f4e305-8042-4e9f-8061-72525532fc1b * Fix Automation suggestion popup placement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24f4e305-8042-4e9f-8061-72525532fc1b * Preserve runtime skill completions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24f4e305-8042-4e9f-8061-72525532fc1b * Fix Automation skill completion acceptance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c69533f7-290b-4cfe-8863-4c2a9c43aec5 * Allow Automation suggestion acceptance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c69533f7-290b-4cfe-8863-4c2a9c43aec5 * Accept Automation suggestions with Tab Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c69533f7-290b-4cfe-8863-4c2a9c43aec5 * Restore Automation skill highlights Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c69533f7-290b-4cfe-8863-4c2a9c43aec5 * Test Automation skill edge edits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c69533f7-290b-4cfe-8863-4c2a9c43aec5 * Fix automation completion test decoration stub Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54febce8-3c95-4100-bf37-0e38cca467d1 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 24f4e305-8042-4e9f-8061-72525532fc1b Copilot-Session: c69533f7-290b-4cfe-8863-4c2a9c43aec5 Copilot-Session: 54febce8-3c95-4100-bf37-0e38cca467d1 --- .../automations/browser/automationDialog.ts | 24 +++- .../browser/automationDialogService.ts | 5 +- .../browser/automationInputCompletions.ts | 128 +++++++++++++++++- .../test/browser/automationDialog.test.ts | 78 ++++++++++- .../automationInputCompletions.test.ts | 118 +++++++++++++--- .../input/editor/chatInputEditorContrib.ts | 10 +- .../editor/chatInputReferenceDecorations.ts | 20 +++ 7 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 9a68ffb5b17f1f..18d8feeec04428 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -21,6 +21,9 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { Context as SuggestContext } from '../../../../editor/contrib/suggest/browser/suggest.js'; +import { State as SuggestState } from '../../../../editor/contrib/suggest/browser/suggestModel.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ActionListItemKind, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; @@ -72,8 +75,9 @@ export function isAutomationDialogPopupTarget(relatedTarget: HTMLElement): boole ); } -export function isAutomationDialogEditCommand(commandId: string, target: HTMLElement): boolean { - return (commandId === 'undo' || commandId === 'redo') && DOM.isEditableElement(target); +export function shouldPassThroughAutomationDialogCommand(commandId: string, target: HTMLElement): boolean { + return commandId === 'acceptSelectedSuggestion' + || ((commandId === 'undo' || commandId === 'redo') && DOM.isEditableElement(target)); } export async function canSelectAutomationWorkspace( @@ -104,6 +108,7 @@ export function registerAutomationDialogKeyboardNavigation( targetWindow: Window & typeof globalThis, getFocusableElements: () => readonly HTMLElement[], isPopupTarget: (target: HTMLElement) => boolean, + acceptPromptSuggestion: () => boolean = () => false, ): IAutomationDialogKeyboardNavigation { const store = new DisposableStore(); let suppressPopupEscapeKeyUp = false; @@ -134,6 +139,11 @@ export function registerAutomationDialogKeyboardNavigation( if (event.key !== 'Tab') { return; } + if (!event.shiftKey && acceptPromptSuggestion()) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } const focusableElements = visibleFocusableElements(); if (focusableElements.length === 0) { @@ -201,6 +211,7 @@ interface IRenderFormHandle { readonly getBranch: () => string | undefined; readonly waitForAutomationSessionSync: () => Promise; readonly getFocusableElements: () => readonly HTMLElement[]; + readonly acceptPromptSuggestion: () => boolean; } export type AutomationSessionDraftTarget = @@ -1249,6 +1260,14 @@ export function renderForm( // eslint-disable-next-line no-restricted-syntax -- the dialog owns this form subtree and supplies its dynamic focus order. return Array.from(form.querySelectorAll('input, select, textarea, button, a[href], [tabindex]')); }, + acceptPromptSuggestion: () => { + const suggestController = SuggestController.get(chatInput.inputEditor); + if (!suggestController || suggestController.model.state === SuggestState.Idle || !suggestController.widget.value.getFocusedItem()) { + return false; + } + suggestController.acceptSelectedSuggestion(true, false); + return true; + }, }; } @@ -1422,6 +1441,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ when: ContextKeyExpr.and( EditorContextKeys.textInputFocus, ChatContextKeys.inAutomationsDialog, + SuggestContext.Visible.toNegated(), ), primary: KeyCode.Enter, handler: (accessor) => { diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 97b3e28b16ef8d..38e8f2c7f16ba0 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -26,7 +26,7 @@ import { ILanguageModelsService } from '../../../../workbench/contrib/chat/commo import { IHostService } from '../../../../workbench/services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { IFormState, IValidationState, isAutomationDialogEditCommand, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, updateSaveButtonState } from './automationDialog.js'; +import { IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; const $ = DOM.$; @@ -183,13 +183,14 @@ export class AutomationDialogService implements IAutomationDialogService { ...(cancelButton ? [cancelButton.element] : []), ], isAutomationDialogPopupTarget, + handle.acceptPromptSuggestion, )); focusFirst = keyboardNavigation.focusFirst; revalidate = () => updateSaveButtonState(saveButton, state, validation, form, getPrompt, getBranch); revalidate(); }, }, this.keybindingService, this.layoutService, this.hostService, automationDialogAllowableCommands, - (commandId, event) => isAutomationDialogEditCommand(commandId, event.target)), + (commandId, event) => shouldPassThroughAutomationDialogCommand(commandId, event.target)), )); activeContainer.classList.add('automation-dialog-open'); diff --git a/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts index 16f8d04077a194..59c723edc9d2f6 100644 --- a/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts +++ b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts @@ -3,31 +3,64 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; import { CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatInputCompletionItem, IChatSessionsService, isAgentHostTarget } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { AgentHostInputCompletionsBase } from '../../../../workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.js'; +import { registerChatInputReferenceDecorationType } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +const ACCEPT_AUTOMATION_SKILL_COMPLETION_COMMAND = 'sessions.automations.acceptSkillCompletion'; +const AUTOMATION_SKILL_DECORATION_TYPE = 'automation-skill-reference'; + +interface IAcceptAutomationSkillCompletionArgument { + readonly handler: AutomationInputCompletions; + readonly range: Range; + readonly text: string; +} + +CommandsRegistry.registerCommand(ACCEPT_AUTOMATION_SKILL_COMPLETION_COMMAND, (_accessor, argument: IAcceptAutomationSkillCompletionArgument) => { + argument.handler.acceptCompletion(argument.range, argument.text); +}); + export class AutomationInputCompletions extends AgentHostInputCompletionsBase { private readonly registration = this._register(new MutableDisposable()); + private readonly restoreRequest = this._register(new MutableDisposable()); + private readonly restoreScheduler = this._register(new RunOnceScheduler(() => this.restorePersistedSkillReferences(), 200)); + private references: Array<{ decorationId: string; text: string }> = []; + private triggerCharacters: readonly string[] = []; constructor( private readonly editor: ICodeEditor, @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, @IChatSessionsService chatSessionsService: IChatSessionsService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ICodeEditorService codeEditorService: ICodeEditorService, + @ILogService private readonly logService: ILogService, ) { super(languageFeaturesService, chatSessionsService); + this._register(registerChatInputReferenceDecorationType(codeEditorService, AUTOMATION_SKILL_DECORATION_TYPE)); + this._register(this.editor.onDidChangeModelContent(() => { + this.restoreRequest.clear(); + this.updateDecorations(); + this.restoreScheduler.schedule(); + })); + let currentScheme: string | undefined; this._register(autorun(reader => { const session = this.sessionsManagementService.automationSession.read(reader); @@ -37,6 +70,9 @@ export class AutomationInputCompletions extends AgentHostInputCompletionsBase ({ text: match[0], start: match.index, end: match.index + match[0].length })) + .filter(token => this.triggerCharacters.includes(token.text[0])); + if (tokens.length === 0) { + return; + } + + this.restoreRequest.clear(); + const request = new CancellationTokenSource(); + this.restoreRequest.value = toDisposable(() => request.dispose(true)); + void (async () => { + const distinctTokens = [...new Map(tokens.map(token => [token.text, token])).values()]; + const skillResults = await Promise.all(distinctTokens.map(async token => { + const result = await this._chatSessionsService.provideChatInputCompletions(session.resource, { text: value, offset: token.end }, request.token); + return [token.text, result?.items.some(item => + (item.attachment.kind === 'skill' || (item.attachment.kind === 'command' && item.attachment.isSkill)) + && item.insertText.trimEnd() === token.text + ) === true] as const; + })); + if (request.token.isCancellationRequested) { + return; + } + const skillTexts = new Set(skillResults.filter(([, isSkill]) => isSkill).map(([text]) => text)); + const restored = tokens.flatMap(token => + skillTexts.has(token.text) + ? [{ + range: Range.fromPositions(model.getPositionAt(token.start), model.getPositionAt(token.end)), + text: token.text, + }] + : [] + ); + if (session === this.sessionsManagementService.automationSession.get() && model === this.editor.getModel() && model.getValue() === value) { + this.updateDecorations(undefined, restored, true); + } + })().catch(error => { + if (!request.token.isCancellationRequested) { + this.logService.error('[AutomationInputCompletions] Failed to restore persisted skill references', error); + } + }); + } + + private updateDecorations(accepted?: { range: Range; text: string }, restored: readonly { range: Range; text: string }[] = [], replace = false): void { + const model = this.editor.getModel(); + if (!model) { + this.references = []; + return; + } + + const references = replace ? [] : this.references.flatMap(reference => { + const range = model.getDecorationRange(reference.decorationId); + return range && model.getValueInRange(range) === reference.text ? [{ range, text: reference.text }] : []; + }); + if (accepted) { + references.push(accepted); + } + for (const reference of restored) { + if (!references.some(existing => existing.text === reference.text && Range.equalsRange(existing.range, reference.range))) { + references.push(reference); + } + } + const decorationIds = this.editor.setDecorationsByType('chat', AUTOMATION_SKILL_DECORATION_TYPE, references.map(reference => ({ range: reference.range }))); + this.references = decorationIds.map((decorationId, index) => ({ decorationId, text: references[index].text })); + } } diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index cd78db7ee38241..f074e1abadddae 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -15,23 +15,28 @@ import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; +import { Context as SuggestContext } from '../../../../../editor/contrib/suggest/browser/suggest.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js'; import { IListAccessibilityProvider } from '../../../../../base/browser/ui/list/listWidget.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IContext } from '../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ResultKind } from '../../../../../platform/keybinding/common/keybindingResolver.js'; +import { KeybindingsRegistry } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IWorkspaceTrustRequestService, ResourceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { createWorkbenchDialogOptions } from '../../../../../workbench/browser/parts/dialogs/dialog.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { GitRefType, IGitRepository, IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { ISession, ISessionWorkspace, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogEditCommand, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, resolveAutomationModelIdentifier, updateSaveButtonState } from '../../browser/automationDialog.js'; +import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, resolveAutomationModelIdentifier, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; import { AutomationIsolationModel } from '../../common/isolationGroupModel.js'; const FOLDER = URI.file('/workspace'); @@ -51,7 +56,7 @@ function dispatchAutomationDialogCommand(target: HTMLElement, commandId: string) upcastPartial({ activeContainer: document.body }), upcastPartial({}), new Set(), - (id, event) => isAutomationDialogEditCommand(id, event.target), + (id, event) => shouldPassThroughAutomationDialogCommand(id, event.target), ); target.addEventListener('keydown', event => options.keyEventProcessor?.(new StandardKeyboardEvent(event)), { once: true }); return dispatchKey(target, 'keydown', 'z'); @@ -937,23 +942,47 @@ suite('Automation branch picker', () => { suite('Automation dialog keyboard navigation', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('allows undo and redo only for editable controls', () => { + test('passes editor commands through the dialog command filter', () => { const prompt = document.createElement('textarea'); const button = document.createElement('button'); assert.deepStrictEqual({ undoPromptPrevented: dispatchAutomationDialogCommand(prompt, 'undo').defaultPrevented, redoPromptPrevented: dispatchAutomationDialogCommand(prompt, 'redo').defaultPrevented, + acceptSuggestionPromptPrevented: dispatchAutomationDialogCommand(prompt, 'acceptSelectedSuggestion').defaultPrevented, undoButtonPrevented: dispatchAutomationDialogCommand(button, 'undo').defaultPrevented, unrelatedPromptPrevented: dispatchAutomationDialogCommand(prompt, 'workbench.action.files.save').defaultPrevented, }, { undoPromptPrevented: false, redoPromptPrevented: false, + acceptSuggestionPromptPrevented: false, undoButtonPrevented: true, unrelatedPromptPrevented: true, }); }); + test('reserves Enter for suggestions while the suggest widget is visible', () => { + const rule = KeybindingsRegistry.getDefaultKeybindings() + .find(item => item.command === 'workbench.action.chat.automationsDialog.insertNewline'); + const evaluate = (suggestWidgetVisible: boolean) => rule?.when?.evaluate({ + getValue: (key: string) => ({ + [EditorContextKeys.textInputFocus.key]: true, + [ChatContextKeys.inAutomationsDialog.key]: true, + [SuggestContext.Visible.key]: suggestWidgetVisible, + })[key] as T | undefined, + } satisfies IContext) ?? false; + + assert.deepStrictEqual({ + ruleRegistered: !!rule, + withoutSuggestions: evaluate(false), + withSuggestions: evaluate(true), + }, { + ruleRegistered: true, + withoutSuggestions: true, + withSuggestions: false, + }); + }); + test('cycles through visible dialog controls', () => { const container = document.createElement('div'); document.body.append(container); @@ -989,6 +1018,49 @@ suite('Automation dialog keyboard navigation', () => { }); }); + test('accepts a prompt suggestion before moving focus with Tab', () => { + const container = document.createElement('div'); + document.body.append(container); + disposables.add({ dispose: () => container.remove() }); + const targetWindow = DOM.getWindow(container); + const prompt = container.appendChild(document.createElement('textarea')); + const next = container.appendChild(document.createElement('button')); + let acceptedSuggestions = 0; + disposables.add(registerAutomationDialogKeyboardNavigation( + targetWindow, + () => [prompt, next], + () => false, + () => { + acceptedSuggestions++; + return true; + }, + )); + let downstreamKeyDowns = 0; + disposables.add(DOM.addDisposableListener(targetWindow, DOM.EventType.KEY_DOWN, () => downstreamKeyDowns++, true)); + + prompt.focus(); + const shiftTabEvent = dispatchKey(prompt, 'keydown', 'Tab', true); + const activeElementAfterShiftTab = document.activeElement; + prompt.focus(); + const event = dispatchKey(prompt, 'keydown', 'Tab'); + + assert.deepStrictEqual({ + activeElement: document.activeElement, + activeElementAfterShiftTab, + acceptedSuggestions, + defaultPrevented: event.defaultPrevented, + downstreamKeyDowns, + shiftTabDefaultPrevented: shiftTabEvent.defaultPrevented, + }, { + activeElement: prompt, + activeElementAfterShiftTab: next, + acceptedSuggestions: 1, + defaultPrevented: true, + downstreamKeyDowns: 0, + shiftTabDefaultPrevented: true, + }); + }); + test('leaves popup keydown handling active and suppresses its Escape keyup', () => { const container = document.createElement('div'); document.body.append(container); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts index 0045511fbf78ab..a838d1d398bd61 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts @@ -4,17 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Position } from '../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../editor/common/core/range.js'; import { CompletionItemKind, CompletionTriggerKind } from '../../../../../editor/common/languages.js'; import { LanguageFeaturesService } from '../../../../../editor/common/services/languageFeaturesService.js'; +import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js'; import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IChatInputCompletionsParams, IChatInputCompletionsResult, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -50,10 +58,23 @@ class TestChatSessionsService extends mock() { suite('AutomationInputCompletions', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => sinon.restore()); + test('shows agent host skills for the automation draft session', async () => { const languageFeaturesService = new LanguageFeaturesService(); const model = store.add(createTextModel('/', null, undefined, URI.parse('vscode-chat-input:automation'))); - const editor = upcastPartial({ getModel: () => model }); + let decorations: readonly Range[] = []; + const editor = upcastPartial({ + getModel: () => model, + onDidChangeModelContent: Event.None, + setDecorationsByType: (_description, _key, options) => { + decorations = options.map(option => Range.lift(option.range)); + return options.map((_, index) => `decoration-${index}`); + }, + }); + const codeEditorService = upcastPartial({ + registerDecorationType: () => ({ dispose() { } }), + }); const session = upcastPartial({ sessionId: 'automation', resource: URI.parse('agent-host-copilot:automation'), @@ -61,7 +82,7 @@ suite('AutomationInputCompletions', () => { const sessionsManagementService = upcastPartial({ automationSession: constObservable(session), }); - store.add(new AutomationInputCompletions(editor, languageFeaturesService, new TestChatSessionsService(), sessionsManagementService)); + store.add(new AutomationInputCompletions(editor, languageFeaturesService, new TestChatSessionsService(), sessionsManagementService, codeEditorService, new NullLogService())); await timeout(0); const provider = languageFeaturesService.completionProvider.ordered(model)[0]; @@ -72,27 +93,88 @@ suite('AutomationInputCompletions', () => { CancellationToken.None, ); - assert.deepStrictEqual(result?.suggestions.map(item => ({ + const suggestions = result?.suggestions.map(item => ({ label: item.label, insertText: item.insertText, filterText: item.filterText, documentation: item.documentation, kind: item.kind, - })), [ - { - label: { label: '/review ', description: 'Review the workspace' }, - insertText: '/review ', - filterText: '/review ', - documentation: 'Review the workspace', - kind: CompletionItemKind.Text, - }, - { - label: { label: '/runtime-skill ', description: 'Run a runtime skill' }, - insertText: '/runtime-skill ', - filterText: '/runtime-skill ', - documentation: 'Run a runtime skill', - kind: CompletionItemKind.Text, + })); + model.setValue('/review '); + const command = result?.suggestions[0].command; + CommandsRegistry.getCommand(command!.id)!.handler(upcastPartial({}), ...command!.arguments!); + + assert.deepStrictEqual({ suggestions, decorations }, { + suggestions: [ + { + label: { label: '/review ', description: 'Review the workspace' }, + insertText: '/review ', + filterText: '/review ', + documentation: 'Review the workspace', + kind: CompletionItemKind.Text, + }, + { + label: { label: '/runtime-skill ', description: 'Run a runtime skill' }, + insertText: '/runtime-skill ', + filterText: '/runtime-skill ', + documentation: 'Run a runtime skill', + kind: CompletionItemKind.Text, + }, + ], + decorations: [new Range(1, 1, 1, 8)], + }); + }); + + test('restores persisted skill references and removes stale decorations after edits', async () => { + const languageFeaturesService = new LanguageFeaturesService(); + const model = store.add(createTextModel( + '/review then /plan and /runtime-skill plus /unknown', + null, + undefined, + URI.parse('vscode-chat-input:automation'), + )); + let decorations: readonly Range[] = []; + const decorationRanges = new Map(); + const onDidChangeModelContent = store.add(new Emitter()); + sinon.stub(model, 'getDecorationRange').callsFake(decorationId => decorationRanges.get(decorationId) ?? null); + const editor = upcastPartial({ + getModel: () => model, + onDidChangeModelContent: onDidChangeModelContent.event, + setDecorationsByType: (_description, _key, options) => { + decorations = options.map(option => Range.lift(option.range)); + decorationRanges.clear(); + return decorations.map((range, index) => { + const id = `decoration-${index}`; + decorationRanges.set(id, range); + return id; + }); }, - ]); + }); + const codeEditorService = upcastPartial({ + registerDecorationType: () => ({ dispose() { } }), + }); + const session = upcastPartial({ + sessionId: 'automation', + resource: URI.parse('agent-host-copilot:automation'), + }); + const sessionsManagementService = upcastPartial({ + automationSession: constObservable(session), + }); + store.add(new AutomationInputCompletions(editor, languageFeaturesService, new TestChatSessionsService(), sessionsManagementService, codeEditorService, new NullLogService())); + await timeout(0); + model.setValue('/reviewx then /plan and /runtime-skill plus /unknown'); + decorationRanges.set('decoration-1', new Range(1, 25, 1, 39)); + onDidChangeModelContent.fire(upcastPartial({})); + await timeout(250); + const afterRightEdgeEdit = decorations; + model.setValue('/reviewx then /plan and x/runtime-skill plus /unknown'); + decorationRanges.set('decoration-0', new Range(1, 26, 1, 40)); + onDidChangeModelContent.fire(upcastPartial({})); + await timeout(250); + + assert.deepStrictEqual({ afterRightEdgeEdit, afterLeftEdgeEdit: decorations }, { + afterRightEdgeEdit: [new Range(1, 25, 1, 39)], + afterLeftEdgeEdit: [], + }); }); }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index 5aa707b74f71c2..c8c38509f0db6d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -14,7 +14,6 @@ import { EditorOption } from '../../../../../../../editor/common/config/editorOp import { Position } from '../../../../../../../editor/common/core/position.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../../../editor/common/editorCommon.js'; -import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; import { ILabelService } from '../../../../../../../platform/label/common/label.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; import { getInputPlaceholderColor, getRangeForPlaceholder } from './chatInputPlaceholderDecoration.js'; @@ -25,7 +24,7 @@ import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynami import { agentReg, slashReg, variableReg } from '../../../../common/requestParser/chatRequestParser.js'; import { IChatWidget } from '../../../chat.js'; import { ChatWidget } from '../../chatWidget.js'; -import { dynamicVariableDecorationType } from '../../../attachments/chatDynamicVariables.js'; +import { registerChatInputReferenceDecorationType } from './chatInputReferenceDecorations.js'; import { NativeEditContextRegistry } from '../../../../../../../editor/browser/controller/editContext/native/nativeEditContextRegistry.js'; import { TextAreaEditContextRegistry } from '../../../../../../../editor/browser/controller/editContext/textArea/textAreaEditContextRegistry.js'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; @@ -185,12 +184,7 @@ class InputEditorDecorations extends Disposable { backgroundColor: themeColorFromId(chatSlashCommandBackground), borderRadius: '3px' })); - this._register(this.codeEditorService.registerDecorationType(decorationDescription, dynamicVariableDecorationType, { - color: themeColorFromId(chatSlashCommandForeground), - backgroundColor: themeColorFromId(chatSlashCommandBackground), - borderRadius: '3px', - rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges - })); + this._register(registerChatInputReferenceDecorationType(this.codeEditorService)); } private getPlaceholderColor(): string | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts new file mode 100644 index 00000000000000..0956f3fd9dc723 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../../../../base/common/themables.js'; +import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; +import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; +import { dynamicVariableDecorationType } from '../../../attachments/chatDynamicVariables.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; + +export function registerChatInputReferenceDecorationType(codeEditorService: ICodeEditorService, decorationType = dynamicVariableDecorationType): IDisposable { + return codeEditorService.registerDecorationType('chat', decorationType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }); +} From 3a7538f1035c011fca078d9fb0337306c5475545 Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:11:05 -0700 Subject: [PATCH 39/41] sessions: Move Clear Background Into Set Background (#333871) sessions: move clear background into picker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chat.contribution.ts | 46 +++++-------------- .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../test/browser/sessionsRename.test.ts | 2 + 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index c3e0dbbd4dc8e3..05dd28f12566fa 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -48,7 +48,7 @@ import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorAreaFocusContext, IsSessionsWindowContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { NEW_SESSION_ACTION_ID } from '../common/constants.js'; -import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; @@ -57,10 +57,8 @@ import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackground'; -const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.clearAgentSessionsBackground'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackgroundLayout'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(IsSessionsWindowContext, SessionsChatBackgroundAvailableContext); -const CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundConfiguredContext); const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_WHEN = ContextKeyExpr.and(CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, SessionsChatBackgroundImageConfiguredContext); type RecentChatBackgroundTypeItem = IQuickPickItem & { @@ -69,10 +67,14 @@ type RecentChatBackgroundTypeItem = IQuickPickItem & { }; type ChatBackgroundTypeItem = IQuickPickItem & ({ - readonly kind: 'codicons' | 'image'; + readonly kind: 'none' | 'codicons' | 'image'; }) | RecentChatBackgroundTypeItem; const chatBackgroundTypeItems: ChatBackgroundTypeItem[] = [{ + kind: 'none', + label: localize('chat.agentSessions.backgroundType.none.label', "No Background"), + detail: localize('chat.agentSessions.backgroundType.none.detail', "Remove the current chat background."), +}, { kind: 'codicons', label: localize('chat.agentSessions.backgroundType.codicons.label', "Codicons"), detail: localize('chat.agentSessions.backgroundType.codicons.detail', "Use a theme-aware pattern of built-in VS Code icons."), @@ -223,7 +225,7 @@ class SetChatBackgroundAction extends Action2 { const backgroundService = accessor.get(ISessionsChatBackgroundService); const quickInputService = accessor.get(IQuickInputService); const fileDialogService = accessor.get(IFileDialogService); - const backgroundKind = backgroundService.getBackground()?.kind; + const backgroundKind = backgroundService.getBackground()?.kind ?? 'none'; const recentImages = backgroundService.getRecentBackgroundImages(); const recentItems: RecentChatBackgroundTypeItem[] = recentImages.map(image => ({ kind: 'recentImage', @@ -249,6 +251,11 @@ class SetChatBackgroundAction extends Action2 { if (!backgroundType) { return; } + if (backgroundType.kind === 'none') { + await backgroundService.clearBackground(); + status(localize('chat.agentSessions.clearBackground.cleared', "Chat background cleared.")); + return; + } if (backgroundType.kind === 'codicons') { await backgroundService.setBackground(AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET); status(localize('chat.agentSessions.setBackground.codicons', "Chat background set to Codicons.")); @@ -327,35 +334,6 @@ class ChangeChatBackgroundLayoutAction extends Action2 { registerAction2(ChangeChatBackgroundLayoutAction); -class ClearChatBackgroundAction extends Action2 { - - constructor() { - super({ - id: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID, - title: localize2('chat.agentSessions.clearBackground', "Clear Background"), - category: CHAT_CATEGORY, - precondition: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, - menu: [{ - id: MenuId.CommandPalette, - when: CLEAR_AGENT_SESSIONS_CHAT_BACKGROUND_WHEN, - }, { - id: Menus.SessionChatBackgroundContext, - group: 'navigation', - order: 3, - when: ContextKeyExpr.and(SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext), - }], - }); - } - - override async run(accessor: ServicesAccessor): Promise { - await accessor.get(ISessionsChatBackgroundService).clearBackground(); - status(localize('chat.agentSessions.clearBackground.cleared', "Chat background cleared.")); - } -} - -registerAction2(ClearChatBackgroundAction); - - // register actions registerAction2(BranchChatSessionAction); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 1c33e234e1f82e..968d7c1cf6a87c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -51,7 +51,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); - content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose the built-in theme-aware Codicons pattern, choose a new image, or reuse one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Use Clear Background to remove either background. These commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images, and Clear Background is shown only when the current color theme has a background. Background customization is unavailable while a high contrast theme is active.")); + content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose no background, the built-in theme-aware Codicons pattern, a new image, or one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Both commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images. Background customization is unavailable while a high contrast theme is active.")); content.push(localize('sessionsChat.vscodePet', "Use the checked Pet item in the new-session view context menu, or type /vscode-pet, to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.")); content.push(localize('sessionsChat.vscodePetAchievements', "When the pet is enabled, the user account menu lists unlocked achievement badges before locked badges and provides a View Achievements button. A gold star on the pet announces a newly unlocked achievement; activate the pet while the star is visible to open Achievements.")); content.push(localize('sessionsChat.aquariumAction', "To show or hide the aquarium action on the new-session view, use the checked Aquarium item in the context menu outside the composer, or run the Toggle Aquarium Action Visibility command.")); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts index 5a51f890cf51d7..fc46a989b638f5 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -337,6 +337,7 @@ suite('Sessions rename', () => { hasPermanentDelete: content.includes('open its context menu and choose Delete'), hasDevContainerAvailability: content.includes('Docker is available') && content.includes('selected local folder contains a Dev Container configuration'), hasDevContainerExecution: content.includes('run the session on an Agent Host inside that folder\'s Dev Container'), + hasNoBackgroundOption: content.includes('choose no background'), hasPetAchievements: content.includes('View Achievements'), activeElement: mainWindow.document.activeElement, fallbackFocusCount: fallbackFocusCount(), @@ -349,6 +350,7 @@ suite('Sessions rename', () => { hasPermanentDelete: true, hasDevContainerAvailability: true, hasDevContainerExecution: true, + hasNoBackgroundOption: true, hasPetAchievements: true, activeElement: origin, fallbackFocusCount: 0, From 22652e6b92131243c352c36ff62e8de6d26079e9 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:17:07 -0700 Subject: [PATCH 40/41] agentHost: classify Copilot SKU telemetry context (#333853) * agentHost: classify Copilot SKU telemetry context Declare the dynamically injected copilotSku property on each applicable Agent Host event so telemetry extraction generates ingestion schemas that accept it. Keep runtime population centralized through the common-property path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: clear stale Copilot SKU telemetry Allow common telemetry properties to be removed so account transitions clear the previous SKU before resolving the current account. Classify the same injected property on forwarded Copilot SDK event schemas. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: compose shared telemetry context Name the common initiator and Copilot SKU schema composition and use event-specific aliases when enriching generic telemetry types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: complete Copilot SKU telemetry coverage Classify SKU context on Agent Host edit attribution and unhandled error events. Add regression coverage for authentication transitions, stale SKU resolution, and restricted envelope clearing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../code-no-telemetry-common-property.ts | 1 + .../node/agentHostChangesetTelemetry.ts | 8 +-- .../node/agentHostRestrictedTelemetry.ts | 12 ++-- .../node/agentHostSessionOpenTelemetry.ts | 5 +- .../node/agentHostTelemetryReporter.ts | 70 ++++++++++++------- .../node/agentHostTelemetryService.ts | 2 +- .../node/agentSdkDownloadTelemetry.ts | 5 +- .../platform/agentHost/node/agentService.ts | 5 +- .../agentHost/node/copilot/copilotAgent.ts | 2 +- .../node/copilot/copilotAgentSession.ts | 6 +- .../node/copilot/copilotFailureTelemetry.ts | 22 +++--- .../copilotGitHubTelemetryForwarder.ts | 1 + .../node/copilot/copilotTodoStoreTelemetry.ts | 6 +- .../agentHost/node/shared/editArcReporter.ts | 10 ++- .../node/shared/editSurvivalReporter.ts | 6 +- .../node/agentHostRestrictedTelemetry.test.ts | 22 ++++++ .../node/agentHostTelemetryService.test.ts | 34 ++++++--- .../agentHost/test/node/copilotAgent.test.ts | 38 ++++++++++ .../browser/forwardingTelemetryService.ts | 2 +- .../telemetry/common/editTelemetry.ts | 4 ++ .../telemetry/common/errorTelemetry.ts | 9 ++- src/vs/platform/telemetry/common/telemetry.ts | 4 +- .../telemetry/common/telemetryService.ts | 8 ++- .../test/browser/telemetryService.test.ts | 13 ++-- .../telemetry/browser/telemetryService.ts | 2 +- .../electron-browser/telemetryService.ts | 2 +- 26 files changed, 211 insertions(+), 88 deletions(-) diff --git a/.eslint-plugin-local/code-no-telemetry-common-property.ts b/.eslint-plugin-local/code-no-telemetry-common-property.ts index c6e62831f8d77f..5634227972b47b 100644 --- a/.eslint-plugin-local/code-no-telemetry-common-property.ts +++ b/.eslint-plugin-local/code-no-telemetry-common-property.ts @@ -49,6 +49,7 @@ const commonTelemetryProperties = new Set([ 'common.copilottrackingid', 'common.copilotsdkversion', 'common.copilotruntimeversion', + 'copilotsku', 'common.isagentswindow', ]); diff --git a/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts b/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts index 55436558c1bfb1..86b01f40bdf59f 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts @@ -7,7 +7,7 @@ import { URI } from '../../../base/common/uri.js'; import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentSession } from '../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from './agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from './agentHostTelemetryReporter.js'; /** The static changeset slot a compute was for. */ export type StaticChangesetTelemetryKind = 'branch' | 'session' | 'uncommitted'; @@ -114,7 +114,7 @@ export type ChangesetComputedKind = StaticChangesetTelemetryKind | 'turn'; /** The union of static and per-turn compute outcomes. */ export type ChangesetComputedOutcome = StaticChangesetOutcome | TurnChangesetOutcome; -type ChangesetComputedEvent = IAgentHostInitiatorTelemetry & { +type ChangesetComputedEvent = IAgentHostEventTelemetry & { provider: string; agentSessionId: string; turnId?: string; @@ -131,7 +131,7 @@ type ChangesetComputedEvent = IAgentHostInitiatorTelemetry & { trackedEditFallbackFolderCount?: number; }; -type ChangesetComputedClassification = IAgentHostInitiatorClassification & { +type ChangesetComputedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; turnId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'For a turn changeset, the turn whose changeset was computed; for a static changeset, the turn that drove the recompute when one did (absent for truncation/refresh recomputes).' }; @@ -154,7 +154,7 @@ type ChangesetComputedClassification = IAgentHostInitiatorClassification & { * Shared emitter for `agentHost.changesetComputed`. Correlation (`provider`, * `agentSessionId`) is derived from `session`; `turnId` is included when set. */ -function reportChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, fields: Omit, clientContext?: IAgentHostClientTelemetryContext): void { +function reportChangesetComputed(telemetryService: ITelemetryService, session: string, turnId: string | undefined, fields: Omit, clientContext?: IAgentHostClientTelemetryContext): void { telemetryService.publicLog2('agentHost.changesetComputed', { ...toInitiatorTelemetry(clientContext), provider: URI.parse(session).scheme, diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts index e81ccd53e2f8e1..ab4150c8423a88 100644 --- a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -133,8 +133,8 @@ export interface IAgentHostRestrictedTelemetry { sendInternalMSFTTelemetryEventForContext(context: IAgentHostInternalTelemetryContext, eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; /** Sets the Copilot user tracking id (`copilot_trackingId`) carried on every subsequent event. */ setCopilotTrackingId(trackingId: string | undefined): void; - /** Adds a property carried on every subsequent event, mirroring `ITelemetryService.setCommonProperty`. */ - setCommonProperty(name: string, value: string | boolean): void; + /** Adds or removes a property carried on every subsequent event, mirroring `ITelemetryService.setCommonProperty`. */ + setCommonProperty(name: string, value: string | boolean | undefined): void; /** Overrides the POST endpoint with the user's CAPI `endpoints.telemetry`; falsy restores the default. */ setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void; /** Enables enhanced GH telemetry once the authenticated account opts in; off by default and on flip/logout. */ @@ -229,8 +229,12 @@ export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedT this._commonProps.copilot_trackingId = trackingId || undefined; } - setCommonProperty(name: string, value: string | boolean): void { - this._commonProps[name] = String(value); + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this._commonProps[name]; + } else { + this._commonProps[name] = String(value); + } } setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { diff --git a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts index 1406ab8c073987..bb1383b71bc6a4 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts @@ -12,6 +12,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import type { AgentProvider } from '../common/agent.js'; import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; +import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; export const AgentHostSessionSubscribeTimeoutMs = 60_000; @@ -37,7 +38,7 @@ export interface IAgentHostSessionOpenTelemetry { export const IAgentHostSessionOpenTelemetry = createDecorator('agentHostSessionOpenTelemetry'); -type AgentHostSessionSubscribeEvent = { +type AgentHostSessionSubscribeEvent = IAgentHostCopilotSkuTelemetry & { provider: string; channel: string; outcome: string; @@ -53,7 +54,7 @@ type AgentHostSessionSubscribeEvent = { totalDurationMs: number; }; -type AgentHostSessionSubscribeClassification = { +type AgentHostSessionSubscribeClassification = IAgentHostCopilotSkuClassification & { owner: 'roblourens'; comment: 'Measures Agent Host subscription latency from the subscribe request through session restoration and provider-specific resume work to the returned snapshot.'; provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Agent provider identifier.' }; diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 12b4e402539f1d..f8d91f3aaea88d 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -42,6 +42,14 @@ export function getMessageOriginTelemetryKind(message: Message): AgentHostMessag return message.origin.kind; } +export interface IAgentHostCopilotSkuTelemetry { + copilotSku?: string; +} + +export type IAgentHostCopilotSkuClassification = { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; +}; + export interface IAgentHostInitiatorTelemetry { initiatorClientType?: AgentHostClientType; initiatorConnectionKind?: AgentHostClientConnectionKind; @@ -60,7 +68,11 @@ export type IAgentHostInitiatorClassification = { initiatorDevDeviceId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'SqmMachineId'; comment: 'The development device identifier of the VS Code client that initiated the event.' }; }; -export interface IAgentHostExecutionModeChangedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostEventTelemetry extends IAgentHostInitiatorTelemetry, IAgentHostCopilotSkuTelemetry { } + +export type IAgentHostEventClassification = IAgentHostInitiatorClassification & IAgentHostCopilotSkuClassification; + +export interface IAgentHostExecutionModeChangedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -69,7 +81,7 @@ export interface IAgentHostExecutionModeChangedEvent extends IAgentHostInitiator turnCount: number; } -export type IAgentHostExecutionModeChangedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostExecutionModeChangedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the mode change belongs to a subagent session.' }; @@ -80,7 +92,7 @@ export type IAgentHostExecutionModeChangedClassification = IAgentHostInitiatorCl comment: 'Reports agent host execution mode changes.'; }; -export interface IAgentHostUserMessageSentEvent { +export interface IAgentHostUserMessageSentEvent extends IAgentHostCopilotSkuTelemetry { provider: string; hostLaunchKind: AgentHostLaunchKind; initiatorClientId: string | undefined; @@ -100,7 +112,7 @@ export interface IAgentHostUserMessageSentEvent { attachmentCount: number; } -export type IAgentHostUserMessageSentClassification = { +export type IAgentHostUserMessageSentClassification = IAgentHostCopilotSkuClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; hostLaunchKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' }; initiatorClientId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The opaque AHP client identifier that initiated the message.' }; @@ -124,7 +136,7 @@ export type IAgentHostUserMessageSentClassification = { export type AgentHostClientConnectionAction = 'connected' | 'disconnected'; -export interface IAgentHostClientConnectionEvent { +export interface IAgentHostClientConnectionEvent extends IAgentHostCopilotSkuTelemetry { action: AgentHostClientConnectionAction; hostLaunchKind: AgentHostLaunchKind; clientId: string; @@ -144,7 +156,7 @@ export interface IAgentHostClientConnectionEvent { subscriptionCount: number | undefined; } -export type IAgentHostClientConnectionClassification = { +export type IAgentHostClientConnectionClassification = IAgentHostCopilotSkuClassification & { action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether an initialized AHP client transport connected or disconnected.' }; hostLaunchKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the agent host process was launched by the VS Code main process or VS Code CLI.' }; clientId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The opaque AHP client identifier.' }; @@ -192,7 +204,7 @@ interface IAgentHostTurnAttributedReport { clientContext?: IAgentHostClientTelemetryContext; } -export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -221,7 +233,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemet modelCallCount: number; } -export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostTurnCompletedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the agent host session.' }; @@ -252,7 +264,7 @@ export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassific comment: 'Tracks agent host turn completion, including performance, configuration context, completed model responses, and billed AI credit usage when reported by the provider.'; }; -export interface IAgentHostTurnFailedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -268,7 +280,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostInitiatorTelemetry callstack: string | undefined; } -export type IAgentHostTurnFailedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the failed agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -388,7 +400,7 @@ function normalizeTurnActivityKind(activityKind: string): AgentHostTurnActivityT return turnActivityKindsByActionType[activityKind as keyof typeof turnActivityKindsByActionType] ?? 'other'; } -export interface IAgentHostTurnHungEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostTurnHungEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -415,7 +427,7 @@ export interface IAgentHostTurnHungEvent extends IAgentHostInitiatorTelemetry { permissionLevel: string | undefined; } -export type IAgentHostTurnHungClassification = IAgentHostInitiatorClassification & { +export type IAgentHostTurnHungClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the hung agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -467,7 +479,7 @@ export interface IAgentHostTurnHungReport extends IAgentHostTurnAttributedReport permissionLevel: string | undefined; } -export interface IAgentHostHungTurnCompletedEvent extends IAgentHostInitiatorTelemetry { +export interface IAgentHostHungTurnCompletedEvent extends IAgentHostEventTelemetry { provider: string; agentSessionId: string; chatSessionId: string; @@ -480,7 +492,7 @@ export interface IAgentHostHungTurnCompletedEvent extends IAgentHostInitiatorTel timeAfterHangMs: number; } -export type IAgentHostHungTurnCompletedClassification = IAgentHostInitiatorClassification & { +export type IAgentHostHungTurnCompletedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the recovered agent host turn.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; @@ -506,6 +518,10 @@ export interface IAgentHostHungTurnCompletedReport extends IAgentHostTurnAttribu timeAfterHangMs: number; } +type IAgentHostLanguageModelToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentHostEventTelemetry; + +type IAgentHostLanguageModelToolInvokedClassification = LanguageModelToolInvokedClassification & IAgentHostEventClassification; + export interface IAgentHostToolInvokedReport extends IAgentHostTurnAttributedReport { provider: string; session: string; @@ -522,7 +538,7 @@ export interface IAgentHostToolInvokedReport extends IAgentHostTurnAttributedRep errorMessage: string | undefined; } -export type IAgentHostToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentHostInitiatorTelemetry & { +export type IAgentHostToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentHostEventTelemetry & { provider: string; agentSessionId: string; chatSessionId: string; @@ -531,7 +547,7 @@ export type IAgentHostToolInvokedEvent = LanguageModelToolInvokedEvent & IAgentH msg: string | undefined; }; -export type IAgentHostToolInvokedClassification = Omit & IAgentHostInitiatorClassification & { +export type IAgentHostToolInvokedClassification = Omit & IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that invoked the tool.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the Agent Host session.' }; @@ -542,7 +558,7 @@ export type IAgentHostToolInvokedClassification = Omit('languageModelToolInvoked', { + this._telemetryService.publicLog2('languageModelToolInvoked', { ...toInitiatorTelemetry(report.clientContext), result: report.result, chatSessionId: session, diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index d6b7dbc68bb071..0a5fa70770dc5f 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -203,7 +203,7 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT this._delegate.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this._delegate.setCommonProperty(name, value); this._restricted?.setCommonProperty(name, value); } diff --git a/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts index 8993588eefe71a..4c35964638ce10 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts @@ -5,6 +5,7 @@ import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; import type { IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; // #region Failure classification @@ -66,7 +67,7 @@ export function classifyAgentSdkDownloadFailure(error: string | undefined): Agen // #region Telemetry -interface IAgentSdkDownloadEvent { +interface IAgentSdkDownloadEvent extends IAgentHostCopilotSkuTelemetry { packageId: string; phase: string; failureReason: string; @@ -76,7 +77,7 @@ interface IAgentSdkDownloadEvent { totalBytes: number; } -type AgentSdkDownloadClassification = { +type AgentSdkDownloadClassification = IAgentHostCopilotSkuClassification & { packageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which agent SDK was being fetched, e.g. claude or codex.' }; phase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the download started, completed, or failed.' }; failureReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Coarse bucket for a failed download (cancelled, network, filesystem, extract, notConfigured, unsupportedTarget, unknown). Empty unless the phase is failed.' }; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e31e50b2c90982..2581cc0954ff01 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -90,6 +90,7 @@ import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../c import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; +import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; @@ -124,7 +125,7 @@ interface ISessionListComputation { trailing?: Promise; } -type AgentHostLegacyMigrationEvent = { +type AgentHostLegacyMigrationEvent = IAgentHostCopilotSkuTelemetry & { provider: string; outcome: 'migrated' | 'skipped' | 'failed'; success: boolean; @@ -137,7 +138,7 @@ type AgentHostLegacyMigrationEvent = { reason: string; }; -type AgentHostLegacyMigrationClassification = { +type AgentHostLegacyMigrationClassification = IAgentHostCopilotSkuClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent provider id whose legacy session was migrated (e.g. copilotcli).' }; outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Migration outcome: migrated (adoption + restore completed), skipped (eligible legacy session not adopted this pass, e.g. migrate flag not yet applied), or failed (adoption or restore threw).' }; success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migration completed with at least one restored turn.' }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 848066cc5cb140..e3953553cab7d5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1550,6 +1550,7 @@ export class CopilotAgent extends Disposable implements IAgent { return; } this._logService.info(`[Copilot] Auth token ${token ? 'updated' : 'cleared'}`); + this._telemetryService.setCommonProperty('copilotSku', undefined); this._githubToken = token; this._updateRestrictedTelemetry(token); this._refreshProxy(); @@ -1598,7 +1599,6 @@ export class CopilotAgent extends Disposable implements IAgent { try { const copilotSku = await this._copilotApiService.resolveCopilotSku?.(githubToken); if (copilotSku && this._githubToken === githubToken) { - // __GDPR__COMMON__ "copilotSku" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The raw Copilot entitlement SKU of the authenticated GitHub account." } this._telemetryService.setCommonProperty('copilotSku', copilotSku); } } catch (err) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index b7e7540d2174bb..e61218f1cbbf93 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -59,7 +59,7 @@ import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME } from './toolSearchDeferral.js'; import { ActiveClientToolSet } from '../activeClientState.js'; -import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; import { AgentHostRepoInfoTelemetry } from '../agentHostRepoInfoTelemetry.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; @@ -5884,7 +5884,7 @@ export class CopilotAgentSession extends Disposable { } } - type AgentHostInstructionsCollectedEvent = IAgentHostInitiatorTelemetry & { + type AgentHostInstructionsCollectedEvent = IAgentHostEventTelemetry & { provider: string; agentSessionId: string; isSubagentSession: boolean; @@ -5894,7 +5894,7 @@ export class CopilotAgentSession extends Disposable { referencedInstructionsCount: number; claudeMdCount: number; }; - type AgentHostInstructionsCollectedClassification = IAgentHostInitiatorClassification & { + type AgentHostInstructionsCollectedClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that emitted this event (e.g. copilotcli). Absent on local rows; use presence to distinguish AH from local.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier. Absent on local rows.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the emission was from a subagent session.' }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts index d62d42d19fcfa8..a2c8e018f57805 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts @@ -11,7 +11,7 @@ import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostCopilotSkuClassification, type IAgentHostCopilotSkuTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; export type CopilotClientOperation = 'abort' | 'changeAgent' | 'changeModel' | 'getSessionMetadata' | 'listSessions' | 'modelRefresh' | 'resumeTurn' | 'sendMessage'; export type CopilotClientOperationFailureKind = 'clientNotConnected' | 'connectionClosed' | 'connectionDisposed' | 'runtimeConnectionClosed'; @@ -26,14 +26,14 @@ export class CopilotClientStartupConfigChangedError extends Error { } } -export interface ICopilotFailureCorrelation extends IAgentHostInitiatorTelemetry { +export interface ICopilotFailureCorrelation extends IAgentHostEventTelemetry { readonly agentSessionId?: string; readonly chatSessionId?: string; readonly turnId?: string; readonly sdkSessionId?: string; } -type CopilotSessionFailureCorrelation = IAgentHostInitiatorTelemetry & { +type CopilotSessionFailureCorrelation = IAgentHostEventTelemetry & { readonly agentSessionId: string; readonly chatSessionId: string; readonly turnId: string | undefined; @@ -115,7 +115,7 @@ type CopilotClientOperationFailureEvent = ICopilotFailureCorrelation & { callstack: string | undefined; }; -type CopilotClientOperationFailureClassification = IAgentHostInitiatorClassification & { +type CopilotClientOperationFailureClassification = IAgentHostEventClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by detections and recovery telemetry for one Copilot client failure episode.' }; failureKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded category of Copilot client failure that was detected.' }; operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Copilot provider operation that detected the client failure.' }; @@ -133,7 +133,7 @@ type CopilotClientOperationFailureClassification = IAgentHostInitiatorClassifica comment: 'Tracks failures detected while operating an established Copilot client and whether recovery was started.'; }; -type CopilotClientStartupEvent = { +type CopilotClientStartupEvent = IAgentHostCopilotSkuTelemetry & { outcome: CopilotClientStartupOutcome; durationMs: number; attemptNumber: number; @@ -142,7 +142,7 @@ type CopilotClientStartupEvent = { startupExitCode?: number; }; -type CopilotClientStartupClassification = { +type CopilotClientStartupClassification = IAgentHostCopilotSkuClassification & { outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the startup attempt succeeded, failed, or was cancelled during shutdown.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Wall-clock duration of the Copilot client startup attempt in milliseconds.' }; attemptNumber: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'One-based Copilot client startup attempt number within this Agent Host process.' }; @@ -273,7 +273,7 @@ export function reportCopilotClientOperationFailure( }); } -type CopilotClientRecoveryEvent = { +type CopilotClientRecoveryEvent = IAgentHostCopilotSkuTelemetry & { clientFailureId: string; failureKind: CopilotClientOperationFailureKind; durationMs: number; @@ -281,7 +281,7 @@ type CopilotClientRecoveryEvent = { stopSucceeded: boolean; }; -type CopilotClientRecoveryClassification = { +type CopilotClientRecoveryClassification = IAgentHostCopilotSkuClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by detections and recovery telemetry for one Copilot client failure episode.' }; failureKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded category of Copilot client failure that initiated recovery.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent recovering the failed Copilot client.' }; @@ -299,7 +299,7 @@ type CopilotClientRecoveryTurnEvent = CopilotSessionFailureCorrelation & { clientFailureId: string; }; -type CopilotClientRecoveryTurnClassification = IAgentHostInitiatorClassification & { +type CopilotClientRecoveryTurnClassification = IAgentHostEventClassification & { clientFailureId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Identifier shared by all telemetry for one Copilot client failure episode.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; @@ -330,7 +330,7 @@ type CopilotSdkSessionErrorEvent = CopilotSessionFailureCorrelation & { callstack: string | undefined; }; -type CopilotSdkSessionErrorClassification = IAgentHostInitiatorClassification & { +type CopilotSdkSessionErrorClassification = IAgentHostEventClassification & { agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host turn identifier, when available.' }; @@ -394,7 +394,7 @@ type CopilotModelCallFailureEvent = CopilotSessionFailureCorrelation & { imagePartsMissingMediaType: number | undefined; }; -type CopilotModelCallFailureClassification = IAgentHostInitiatorClassification & { +type CopilotModelCallFailureClassification = IAgentHostEventClassification & { agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host chat identifier.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The Agent Host turn identifier, when available.' }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index 7669c43571dca0..f1598c9296c2fc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -21,6 +21,7 @@ import { ITelemetryData, ITelemetryService } from '../../../telemetry/common/tel "os_arch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Operating system architecture of the Copilot CLI runtime." }, "node_version": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Node.js version of the Copilot CLI runtime." }, "copilot_plan": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Copilot subscription plan reported by the runtime." }, + "copilotSku": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The raw Copilot entitlement SKU for the authenticated GitHub account." }, "client_type": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Type of client that produced the event." }, "client_name": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Name of the client that produced the event." }, "dev_device_id": { "classification": "EndUserPseudonymizedInformation", "purpose": "BusinessInsight", "comment": "Pseudonymous device identifier supplied by the runtime." }, diff --git a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts index 27194fc4e30185..44935508ccba2c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts @@ -8,12 +8,12 @@ import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isSubagentSession } from '../../common/state/sessionState.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; type TodoStoreOperation = 'read' | 'write' | 'mixed'; type TodoStoreTarget = 'todos' | 'todo_deps' | 'both'; -type TodoStoreOperationEvent = IAgentHostInitiatorTelemetry & { +type TodoStoreOperationEvent = IAgentHostEventTelemetry & { operation: TodoStoreOperation; target: TodoStoreTarget; toolCallId: string; @@ -22,7 +22,7 @@ type TodoStoreOperationEvent = IAgentHostInitiatorTelemetry & { isSubagentSession: boolean; }; -type TodoStoreOperationClassification = IAgentHostInitiatorClassification & { +type TodoStoreOperationClassification = IAgentHostEventClassification & { operation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation read from, wrote to, or both read from and wrote to todo storage.' }; target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the SQL operation referenced todo items, todo dependencies, or both.' }; toolCallId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the SQL tool call, used to correlate with generic tool telemetry.' }; diff --git a/src/vs/platform/agentHost/node/shared/editArcReporter.ts b/src/vs/platform/agentHost/node/shared/editArcReporter.ts index bca064fa86dd3b..05488290c9c76e 100644 --- a/src/vs/platform/agentHost/node/shared/editArcReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editArcReporter.ts @@ -23,7 +23,11 @@ import { IDiffComputeService } from '../../common/diffComputeService.js'; import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostTelemetryService, isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; + +type IAgentHostEditArcTelemetryEvent = IEditArcTelemetryEvent & IAgentHostEventTelemetry; + +type IAgentHostEditArcTelemetryClassification = IEditArcTelemetryClassification & IAgentHostEventClassification; export interface IEditArcReporterLaunchParams { readonly clientContext?: IAgentHostClientTelemetryContext; @@ -316,7 +320,7 @@ class EditArcReporter extends Disposable { const provider = AgentSession.provider(sessionUri) ?? 'unknown'; const originalLineCounts = new EditArcTracker(this._params.beforeText, this._params.initialEdit).getLineCountInfo(); const currentLineCounts = this._tracker.getLineCountInfo(); - const event: IEditArcTelemetryEvent & IAgentHostInitiatorTelemetry = { + const event: IAgentHostEditArcTelemetryEvent = { ...toInitiatorTelemetry(this._params.clientContext), sourceKeyCleaned: 'source:Chat.applyEdits', extensionId: undefined, @@ -340,7 +344,7 @@ class EditArcReporter extends Disposable { currentLineCount: currentLineCounts.insertedLineCounts, currentDeletedLineCount: currentLineCounts.deletedLineCounts, }; - this._telemetryService.publicLog2('editTelemetry.reportEditArc', event); + this._telemetryService.publicLog2('editTelemetry.reportEditArc', event); if (provider === 'copilotcli' && isAgentHostTelemetryService(this._telemetryService)) { const { didBranchChange, diff --git a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts index aa93350af89460..61c111b738ad69 100644 --- a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts @@ -14,7 +14,7 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isAhpChatChannel, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; -import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; +import { toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry } from '../agentHostTelemetryReporter.js'; import { computeChunkedEditSurvival, computeWholeFileEditSurvival } from './editSurvivalTracker.js'; /** @@ -84,7 +84,7 @@ export class NullEditSurvivalReporterFactory implements IEditSurvivalReporterFac } } -interface IEditSurvivalTelemetryEvent extends IAgentHostInitiatorTelemetry { +interface IEditSurvivalTelemetryEvent extends IAgentHostEventTelemetry { provider: string; modelId: string; toolName: string; @@ -105,7 +105,7 @@ interface IEditSurvivalTelemetryEvent extends IAgentHostInitiatorTelemetry { currentTextLength: number; } -type IEditSurvivalTelemetryClassification = IAgentHostInitiatorClassification & { +type IEditSurvivalTelemetryClassification = IAgentHostEventClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model that produced the edit, e.g. "claude-sonnet-4.5" or "gpt-5-mini". Empty if the host could not determine the per-edit model.' }; toolName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Name of the edit tool that produced the edit, e.g. "Edit", "apply_patch". Empty if unknown.' }; diff --git a/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts index 6cd0bef7884c1f..1f15c8957d06bf 100644 --- a/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts @@ -106,6 +106,28 @@ suite('AgentHostRestrictedTelemetrySender', () => { }); }); + test('common properties are added to and removed from standard and enhanced envelopes', () => { + const { sender, envelopes } = createSender(); + sender.setRestrictedTelemetryEnabled(true); + + sender.setCommonProperty('copilotSku', 'copilot_for_business_seat'); + sender.sendGHTelemetryEvent('standard'); + sender.sendEnhancedGHTelemetryEvent('enhanced'); + sender.setCommonProperty('copilotSku', undefined); + sender.sendGHTelemetryEvent('standard'); + sender.sendEnhancedGHTelemetryEvent('enhanced'); + + assert.deepStrictEqual(envelopes.map(envelope => ({ + hasCopilotSku: Object.hasOwn(envelope.data.baseData.properties, 'copilotSku'), + copilotSku: envelope.data.baseData.properties.copilotSku, + })), [ + { hasCopilotSku: true, copilotSku: 'copilot_for_business_seat' }, + { hasCopilotSku: true, copilotSku: 'copilot_for_business_seat' }, + { hasCopilotSku: false, copilotSku: undefined }, + { hasCopilotSku: false, copilotSku: undefined }, + ]); + }); + test('oversized enhanced telemetry is not posted when property bytes are below the limit', () => { const logService = new RecordingLogService(); const { sender, posts } = createSender(logService); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index 17f9a188cedcd4..ad267d8e2438de 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -51,8 +51,12 @@ class TestTelemetryService implements ITelemetryService { } setExperimentProperty(): void { } - setCommonProperty(name: string, value: string | boolean): void { - this.commonProperties[name] = value; + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this.commonProperties[name]; + } else { + this.commonProperties[name] = value; + } } } @@ -93,8 +97,12 @@ class TestRestrictedSink implements IAgentHostRestrictedTelemetry { setInternalTelemetryContext(context: IAgentHostInternalTelemetryContext | undefined): void { this.internalContexts.push(context); } - setCommonProperty(name: string, value: string | boolean): void { - this.commonProperties[name] = value; + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this.commonProperties[name]; + } else { + this.commonProperties[name] = value; + } } } @@ -239,16 +247,24 @@ suite('AgentHostTelemetryService', () => { }); }); - test('forwards common properties to standard and restricted telemetry', () => { + test('forwards setting and clearing common properties to standard and restricted telemetry', () => { const delegate = new TestTelemetryService(); const sink = new TestRestrictedSink(); const service = disposables.add(new AgentHostTelemetryService(delegate, sink)); service.setCommonProperty('copilotSku', 'copilot_for_business_seat'); - - assert.deepStrictEqual({ delegate: delegate.commonProperties, restricted: sink.commonProperties }, { - delegate: { copilotSku: 'copilot_for_business_seat' }, - restricted: { copilotSku: 'copilot_for_business_seat' }, + const afterSet = { delegate: { ...delegate.commonProperties }, restricted: { ...sink.commonProperties } }; + service.setCommonProperty('copilotSku', undefined); + + assert.deepStrictEqual({ afterSet, afterClear: { delegate: delegate.commonProperties, restricted: sink.commonProperties } }, { + afterSet: { + delegate: { copilotSku: 'copilot_for_business_seat' }, + restricted: { copilotSku: 'copilot_for_business_seat' }, + }, + afterClear: { + delegate: {}, + restricted: {}, + }, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 47e6020ba4ba14..b9155b6cbf610d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -354,6 +354,7 @@ class TestCopilotApiService implements ICopilotApiService { userLogin: string | undefined; readonly restrictedTelemetryContexts = new Map(); readonly restrictedTelemetryContextCalls: string[] = []; + resolveCopilotSkuHandler: (githubToken: string) => Promise = async () => undefined; messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator; messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; @@ -376,6 +377,7 @@ class TestCopilotApiService implements ICopilotApiService { } async resolveApiEndpoint() { return this.apiEndpoint; } async resolveUserLogin() { return this.userLogin; } + async resolveCopilotSku(githubToken: string): Promise { return this.resolveCopilotSkuHandler(githubToken); } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -574,11 +576,18 @@ class RecordingTelemetryService extends NullTelemetryServiceShape { readonly events: Array<{ eventName: string; data: unknown }> = []; readonly errorEvents: Array<{ eventName: string; data: unknown }> = []; readonly experimentProperties: Record = {}; + readonly commonPropertyUpdates: Array<{ name: string; value: string | boolean | undefined }> = []; override setExperimentProperty(name?: string, value?: string): void { this.experimentProperties[name ?? ''] = value ?? ''; } + override setCommonProperty(name?: string, value?: string | boolean): void { + if (name) { + this.commonPropertyUpdates.push({ name, value }); + } + } + override publicLog2(eventName?: string, data?: unknown): void { this.events.push({ eventName: eventName ?? '', data }); } @@ -2084,6 +2093,35 @@ suite('CopilotAgent', () => { } }); + test('updates Copilot SKU telemetry across authentication changes and ignores stale resolution', async () => { + const client = new TestCopilotClient([]); + const copilotApiService = new TestCopilotApiService(); + const telemetryService = new RecordingTelemetryService(); + copilotApiService.resolveCopilotSkuHandler = async token => token === 'token-a' ? 'sku-a' : 'sku-b'; + const agent = createTestAgent(disposables, { copilotClient: client, copilotApiService, telemetryService }); + try { + await agent.authenticate('https://api.github.com', 'token-a'); + const staleResolution = new DeferredPromise(); + copilotApiService.resolveCopilotSkuHandler = token => token === 'token-a' ? staleResolution.p : Promise.resolve('sku-b'); + const staleResolutionCall = agent['_resolveCopilotSku']('token-a'); + + await agent.authenticate('https://api.github.com', 'token-b'); + staleResolution.complete('stale-sku-a'); + await staleResolutionCall; + await agent.authenticate('https://api.github.com', ''); + + assert.deepStrictEqual(telemetryService.commonPropertyUpdates.filter(update => update.name === 'copilotSku'), [ + { name: 'copilotSku', value: undefined }, + { name: 'copilotSku', value: 'sku-a' }, + { name: 'copilotSku', value: undefined }, + { name: 'copilotSku', value: 'sku-b' }, + { name: 'copilotSku', value: undefined }, + ]); + } finally { + await disposeAgent(agent); + } + }); + test('updates every live session after a changed auth token without restarting an unchanged proxy', async () => { const client = new TestCopilotClient([], [{ id: 'gpt-4o', diff --git a/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts b/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts index 13abd3ce766d18..589f4cbb7d9bda 100644 --- a/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts +++ b/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts @@ -71,7 +71,7 @@ export class InterceptingTelemetryService implements ITelemetryService { this._baseService.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this._baseService.setCommonProperty(name, value); } } diff --git a/src/vs/platform/telemetry/common/editTelemetry.ts b/src/vs/platform/telemetry/common/editTelemetry.ts index 2bb9b4d03bbced..3f96461528abd9 100644 --- a/src/vs/platform/telemetry/common/editTelemetry.ts +++ b/src/vs/platform/telemetry/common/editTelemetry.ts @@ -9,6 +9,7 @@ export type EditTelemetryMode = 'longterm' | '10minFocusWindow' | '20minFocusWin export type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time'; export interface IEditSourcesDetailsTelemetryData { + copilotSku?: string; mode: EditTelemetryMode; sourceKey: string; sourceKeyCleaned: string; @@ -28,6 +29,7 @@ export interface IEditSourcesDetailsTelemetryData { } type EditSourcesDetailsTelemetryClassification = { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; owner: 'hediet'; comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 10-minute or 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub'; mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either longterm, 10minFocusWindow, or 20minFocusWindow.' }; @@ -53,6 +55,7 @@ export function sendEditSourcesDetailsTelemetry(telemetryService: ITelemetryServ } export interface IEditSourcesStatsTelemetryData { + copilotSku?: string; attributionSchemaVersion: 2; mode: EditTelemetryMode; languageId?: string; @@ -78,6 +81,7 @@ export interface IEditSourcesStatsTelemetryData { } type EditSourcesStatsTelemetryClassification = { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; owner: 'hediet'; comment: 'Aggregates character counts by edit source category (user typing, AI completions, NES, IDE actions, external changes) for each editing session. Sessions represent units of work and end when documents close, branches change, commits occur, or time limits are reached (10 or 20 minutes of focus time for visible documents, or 10 hours otherwise). Focus time is computed as accumulated 1-minute blocks where VS Code has focus and there was recent user activity. Tracks both total characters inserted and characters remaining at session end to measure retention. This high-level summary complements editSources.details which provides granular per-source breakdowns. @sentToGitHub'; diff --git a/src/vs/platform/telemetry/common/errorTelemetry.ts b/src/vs/platform/telemetry/common/errorTelemetry.ts index 7d5c8deeceab52..cdb522a1ae24ae 100644 --- a/src/vs/platform/telemetry/common/errorTelemetry.ts +++ b/src/vs/platform/telemetry/common/errorTelemetry.ts @@ -34,8 +34,13 @@ type ListenerLeakDiagFragment = { listenerCount?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Number of listeners on the emitter when the leak was detected.' }; }; -type UnhandledErrorEvent = ErrorEvent & ListenerLeakDiagEvent; -type UnhandledErrorClassification = ErrorEventFragment & ListenerLeakDiagFragment; +type UnhandledErrorEvent = ErrorEvent & ListenerLeakDiagEvent & { + copilotSku?: string; +}; + +type UnhandledErrorClassification = ErrorEventFragment & ListenerLeakDiagFragment & { + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The raw Copilot entitlement SKU for the authenticated GitHub account.' }; +}; export interface ErrorEvent { callstack: string; diff --git a/src/vs/platform/telemetry/common/telemetry.ts b/src/vs/platform/telemetry/common/telemetry.ts index e79684b7dd1434..29b71ef7969153 100644 --- a/src/vs/platform/telemetry/common/telemetry.ts +++ b/src/vs/platform/telemetry/common/telemetry.ts @@ -53,10 +53,10 @@ export interface ITelemetryService { setExperimentProperty(name: string, value: string): void; /** - * Sets a common property that will be attached to all telemetry events. + * Sets a common property that will be attached to all telemetry events, or removes it when the value is undefined. * Common properties are added after PII cleaning and cannot be overridden by event data. */ - setCommonProperty(name: string, value: string | boolean): void; + setCommonProperty(name: string, value: string | boolean | undefined): void; } export function telemetryLevelEnabled(service: ITelemetryService, level: TelemetryLevel): boolean { diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index f3ffc70c898b89..18f60022ff32e2 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -151,8 +151,12 @@ export class TelemetryService implements ITelemetryService { } } - setCommonProperty(name: string, value: string | boolean): void { - this._commonProperties[name] = value; + setCommonProperty(name: string, value: string | boolean | undefined): void { + if (value === undefined) { + delete this._commonProperties[name]; + } else { + this._commonProperties[name] = value; + } } private _flushPendingEvents(): void { diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index 64cca827eb5c02..1447eddaaef6ee 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -227,7 +227,7 @@ suite('TelemetryService', () => { service.dispose(); }); - test('setCommonProperty adds property to all subsequent events', function () { + test('setCommonProperty adds and removes property from subsequent events', function () { const testAppender = new TestTelemetryAppender(); const service = new TelemetryService({ appenders: [testAppender], @@ -236,9 +236,14 @@ suite('TelemetryService', () => { service.publicLog('eventBeforeSet'); service.setCommonProperty('common.copilotTrackingId', 'test-tracking-id'); service.publicLog('eventAfterSet'); - - assert.strictEqual(testAppender.events[0].data['common.copilotTrackingId'], undefined); - assert.strictEqual(testAppender.events[1].data['common.copilotTrackingId'], 'test-tracking-id'); + service.setCommonProperty('common.copilotTrackingId', undefined); + service.publicLog('eventAfterClear'); + + assert.deepStrictEqual(testAppender.events.map(event => event.data['common.copilotTrackingId']), [ + undefined, + 'test-tracking-id', + undefined, + ]); service.dispose(); }); diff --git a/src/vs/workbench/services/telemetry/browser/telemetryService.ts b/src/vs/workbench/services/telemetry/browser/telemetryService.ts index 768fc0c30a3910..daa2c00fdd13e5 100644 --- a/src/vs/workbench/services/telemetry/browser/telemetryService.ts +++ b/src/vs/workbench/services/telemetry/browser/telemetryService.ts @@ -135,7 +135,7 @@ export class TelemetryService extends Disposable implements ITelemetryService { return this.impl.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this.impl.setCommonProperty(name, value); } diff --git a/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts b/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts index 3fb1c7c464082e..98ab319d75ff4c 100644 --- a/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts +++ b/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts @@ -101,7 +101,7 @@ export class TelemetryService extends Disposable implements ITelemetryService { return this.impl.setExperimentProperty(name, value); } - setCommonProperty(name: string, value: string | boolean): void { + setCommonProperty(name: string, value: string | boolean | undefined): void { this.impl.setCommonProperty(name, value); } From 0b0fbc4f221f03295afc30538e44299bb041dd1e Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:19:16 -0700 Subject: [PATCH 41/41] Make browser ownership mutable (#332038) --- .../browserView/common/browserView.ts | 6 +++++ .../browserView/electron-main/browserView.ts | 22 +++++++++++++++---- .../electron-main/browserViewMainService.ts | 12 ++++++++-- .../contrib/browserView/common/browserView.ts | 19 +++++++++++++++- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index f11d0c4ee4c6b9..a604525d9f1612 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -511,6 +511,7 @@ export interface IBrowserViewService { onDynamicDidKeyCommand(id: string): Event; onDynamicDidChangeTitle(id: string): Event; onDynamicDidChangeFavicon(id: string): Event; + onDynamicDidChangeOwner(id: string): Event; onDynamicDidFindInPage(id: string): Event; onDynamicDidClose(id: string): Event; onDynamicDidSelectElement(id: string): Event; @@ -543,6 +544,11 @@ export interface IBrowserViewService { */ destroyBrowserView(id: string): Promise; + /** + * Update the owner of an existing browser view. + */ + setOwner(id: string, owner: IBrowserViewOwner): Promise; + /** * Get the state of an existing browser view by ID, or throw if it doesn't exist * @param id The browser view identifier diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index e3653019c46651..3d5e5a67985d61 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -57,6 +57,7 @@ export class BrowserView extends Disposable { readonly inspector: BrowserViewInspector; private _ownerWindow: ICodeWindow; + private _owner: IBrowserViewOwner; private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; private _audiences: readonly IBrowserViewAudience[] = []; @@ -99,6 +100,9 @@ export class BrowserView extends Disposable { private readonly _onDidChangeFavicon = this._register(new Emitter()); readonly onDidChangeFavicon: Event = this._onDidChangeFavicon.event; + private readonly _onDidChangeOwner = this._register(new Emitter()); + readonly onDidChangeOwner: Event = this._onDidChangeOwner.event; + private readonly _onDidFindInPage = this._register(new Emitter()); readonly onDidFindInPage: Event = this._onDidFindInPage.event; @@ -120,10 +124,10 @@ export class BrowserView extends Disposable { constructor( public readonly id: string, public readonly hostWindowId: number, - public readonly owner: IBrowserViewOwner, + owner: IBrowserViewOwner, public readonly associatedResource: URI | undefined, public readonly session: BrowserSession, - private readonly _createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, editorOptions: IBrowserViewEditorOpenOptions) => BrowserView, + private readonly _createChildView: (owner: IBrowserViewOwner, url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, editorOptions: IBrowserViewEditorOpenOptions) => BrowserView, openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @@ -132,6 +136,7 @@ export class BrowserView extends Disposable { @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); + this._owner = owner; const webPreferences: Electron.WebPreferences = { ...options?.webPreferences, @@ -204,7 +209,7 @@ export class BrowserView extends Disposable { } })()); - const childView = this._createChildView(details.url, options, { + const childView = this._createChildView(this.owner, details.url, options, { pinned: true, background: location === NewPageLocation.Background, parentViewId: id, @@ -591,6 +596,15 @@ export class BrowserView extends Disposable { ); } + get owner(): IBrowserViewOwner { + return this._owner; + } + + setOwner(owner: IBrowserViewOwner): void { + this._owner = owner; + this._onDidChangeOwner.fire(owner); + } + get webContents(): Electron.WebContents { return this._view.webContents; } @@ -748,7 +762,7 @@ export class BrowserView extends Disposable { } logBrowserOpen(this.telemetryService, 'browserLinkForeground'); - this._createChildView(url, undefined, { + this._createChildView(this.owner, url, undefined, { pinned: true, parentViewId: this.id }); diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index a3bae4f47dc273..a21b231300fd9e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -178,6 +178,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeFavicon; } + onDynamicDidChangeOwner(id: string) { + return this._getBrowserView(id).onDidChangeOwner; + } + onDynamicDidFindInPage(id: string) { return this._getBrowserView(id).onDidFindInPage; } @@ -238,6 +242,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this.browserViews.deleteAndDispose(id); } + async setOwner(id: string, owner: IBrowserViewOwner): Promise { + this._getBrowserView(id).setOwner(owner); + } + async layout(id: string, bounds: IBrowserViewBounds): Promise { return this._getBrowserView(id).layout(bounds); } @@ -441,10 +449,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa associatedResource, browserSession, // Child views share their host, owner, and storage, but do not implicitly inherit agent access. - (url, electronOptions, editorOptions) => { + (childOwner, url, electronOptions, editorOptions) => { return this._createBrowserView(generateUuid(), { hostWindowId, - owner, + owner: childOwner, session: browserSession.id, initialUrl: url || undefined }, editorOptions, electronOptions); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 3e97adc005e158..4bb35e476f1b73 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -398,6 +398,7 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidKeyCommand: Event; readonly onDidChangeTitle: Event; readonly onDidChangeFavicon: Event; + readonly onDidChangeOwner: Event; readonly onDidFindInPage: Event; readonly onDidChangeVisibility: Event; readonly onDidClose: Event; @@ -424,6 +425,7 @@ export interface IBrowserViewModel extends IDisposable { stopFindInPage(keepSelection?: boolean): Promise; getSelectedText(): Promise; clearStorage(): Promise; + setOwner(owner: IBrowserViewOwner): Promise; setSharedWithAgent(shared: boolean): Promise; trustCertificate(host: string, fingerprint: string): Promise; untrustCertificate(host: string, fingerprint: string): Promise; @@ -442,6 +444,7 @@ export interface IBrowserViewModel extends IDisposable { export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _url: string = ''; + private _owner: IBrowserViewOwner; private _title: string = ''; private _favicon: string | undefined = undefined; private _screenshot: VSBuffer | undefined = undefined; @@ -483,7 +486,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { constructor( readonly id: string, - readonly owner: IBrowserViewOwner, + owner: IBrowserViewOwner, readonly associatedResource: URI | undefined, initialState: IBrowserViewState, private readonly browserViewService: IBrowserViewService, @@ -496,6 +499,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { @ILogService private readonly logService: ILogService, ) { super(); + this._owner = owner; // Initialize state this._url = initialState.url; @@ -598,6 +602,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._favicon = e.favicon; })); + this._register(this.onDidChangeOwner(owner => { + this._owner = owner; + })); + this._register(this.onDidChangeFocus(({ focused }) => { this._focused = focused; })); @@ -638,6 +646,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } get url(): string { return this._url; } + get owner(): IBrowserViewOwner { return this._owner; } get title(): string { return this._title; } get favicon(): string | undefined { return this._favicon; } get loading(): boolean { return this._loading; } @@ -692,6 +701,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidChangeFavicon(this.id); } + get onDidChangeOwner(): Event { + return this.browserViewService.onDynamicDidChangeOwner(this.id); + } + get onDidFindInPage(): Event { return this.browserViewService.onDynamicDidFindInPage(this.id); } @@ -784,6 +797,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.clearStorage(this.id); } + async setOwner(owner: IBrowserViewOwner): Promise { + return this.browserViewService.setOwner(this.id, owner); + } + async trustCertificate(host: string, fingerprint: string): Promise { return this.browserViewService.trustCertificate(this.id, host, fingerprint); }