From 0f7b8aef913e61b9e39b80929695d214acdda2ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Aug 2026 19:22:48 +0000 Subject: [PATCH 01/21] fix: clamp inverted visible range in ListView to prevent RangeError (fixes #333230) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/list/listView.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index aee77ac1234f98..bd4b69eb17f9cf 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -1560,9 +1560,13 @@ export class ListView implements IListView { } private getVisibleRange(renderTop: number, renderHeight: number): IRange { + const start = this.rangeMap.indexAt(renderTop); return { - start: this.rangeMap.indexAt(renderTop), - end: this.rangeMap.indexAfter(renderTop + renderHeight - 1) + start, + // When the viewport is collapsed or hidden (renderHeight <= 0), the end + // position can resolve to an index before `start`, producing an inverted + // range. Clamp it so consumers always receive `end >= start`. + end: Math.max(start, this.rangeMap.indexAfter(renderTop + renderHeight - 1)) }; } From 61d2314e92f40910223a7165019ff720fcb55d6f Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:49:11 +0000 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20sho?= =?UTF-8?q?rten=20inline=20comment=20and=20add=20zero-height=20regression?= =?UTF-8?q?=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/list/listView.ts | 4 +--- .../test/browser/ui/list/listView.test.ts | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index bd4b69eb17f9cf..c1050dd3ce57d8 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -1563,9 +1563,7 @@ export class ListView implements IListView { const start = this.rangeMap.indexAt(renderTop); return { start, - // When the viewport is collapsed or hidden (renderHeight <= 0), the end - // position can resolve to an index before `start`, producing an inverted - // range. Clamp it so consumers always receive `end >= start`. + // Clamp so a collapsed viewport (renderHeight <= 0) cannot produce an inverted range. end: Math.max(start, this.rangeMap.indexAfter(renderTop + renderHeight - 1)) }; } diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 98f603971a0762..4043b572869505 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -574,7 +574,29 @@ suite('ListView', function () { listView.dispose(); selection.removeAllRanges(); document.dispatchEvent(new Event('selectionchange')); - element.remove(); + test('does not throw when laid out at zero height with dynamic heights', function () { + type TestElement = { height: number }; + const delegate: IListVirtualDelegate = { + getHeight() { return 100; }, + getTemplateId() { return 'template'; }, + getDynamicHeight(element) { return element.height; } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate() { }, + renderElement() { }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(200, 200); + listView.splice(0, 0, elements); + listView.setScrollTop(100); + assert.doesNotThrow(() => listView.layout(0, 200)); + } finally { + listView.dispose(); } }); }); From 055d71cef5eba530c7fb14a37e5740c3a7edb886 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:58:59 +0000 Subject: [PATCH 03/21] fix: repair regression test structure and reproduce inverted range Restore the accidentally-removed teardown/closing of the shift-click selection test and rewrite the zero-height regression test so it actually triggers the inverted range: with zero-height dynamic items and a collapsed viewport, getVisibleRange previously produced { start: 3, end: 0 } which crashed probeDynamicHeights. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/ui/list/listView.test.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 4043b572869505..6901a424a0c96c 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -574,10 +574,14 @@ suite('ListView', function () { listView.dispose(); selection.removeAllRanges(); document.dispatchEvent(new Event('selectionchange')); - test('does not throw when laid out at zero height with dynamic heights', function () { + element.remove(); + } + }); + + test('does not throw when laid out with a collapsed viewport and zero-height dynamic items', function () { type TestElement = { height: number }; const delegate: IListVirtualDelegate = { - getHeight() { return 100; }, + getHeight() { return 0; }, getTemplateId() { return 'template'; }, getDynamicHeight(element) { return element.height; } }; @@ -588,13 +592,15 @@ suite('ListView', function () { disposeTemplate() { } }; - const elements: TestElement[] = [{ height: 40 }, { height: 100 }, { height: 160 }]; + const elements: TestElement[] = [{ height: 0 }, { height: 0 }, { height: 0 }]; const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); try { - listView.layout(200, 200); - listView.splice(0, 0, elements); - listView.setScrollTop(100); - assert.doesNotThrow(() => listView.layout(0, 200)); + // Collapse the viewport first so a subsequent splice re-renders with renderHeight <= 0. + // With zero-height items indexAt(renderTop) resolves to the item count while + // indexAfter(renderTop - 1) clamps to 0, so getVisibleRange previously produced an + // inverted range (e.g. { start: 3, end: 0 }) that crashed probeDynamicHeights. + listView.layout(0, 200); + assert.doesNotThrow(() => listView.splice(0, 0, elements)); } finally { listView.dispose(); } From cb7a02b2b836eb7516cb2809aeef241e2c48caa2 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:37:50 +0000 Subject: [PATCH 04/21] =?UTF-8?q?fix:=20address=20review=20on=20listView.t?= =?UTF-8?q?est.ts:601=20=E2=80=94=20condense=20regression=20comment=20to?= =?UTF-8?q?=20one=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/test/browser/ui/list/listView.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 6901a424a0c96c..4d5dc86be9b436 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -595,10 +595,7 @@ suite('ListView', function () { const elements: TestElement[] = [{ height: 0 }, { height: 0 }, { height: 0 }]; const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); try { - // Collapse the viewport first so a subsequent splice re-renders with renderHeight <= 0. - // With zero-height items indexAt(renderTop) resolves to the item count while - // indexAfter(renderTop - 1) clamps to 0, so getVisibleRange previously produced an - // inverted range (e.g. { start: 3, end: 0 }) that crashed probeDynamicHeights. + // Collapsing the viewport before splicing zero-height items previously yielded an inverted range that crashed probeDynamicHeights. listView.layout(0, 200); assert.doesNotThrow(() => listView.splice(0, 0, elements)); } finally { From 14060e61e4b6c9f5a5cc79a6120faa2b41f13524 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 31 Aug 2026 10:49:10 -0700 Subject: [PATCH 05/21] accounts: preserve sign-in during session replacement Keep the default account available while authentication providers replace a removed session with a new candidate in the same event. Add privacy-safe logs to distinguish interactive and secret-storage replacements. Fixes #333197 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github-authentication/src/github.ts | 12 ++++ .../accounts/browser/defaultAccount.ts | 16 ++++- .../test/browser/defaultAccount.test.ts | 63 +++++++++++++++++-- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index 0c3b56bf3ea736..5d8ff26b966cf1 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -572,6 +572,16 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid } } + private logSessionChange(source: 'secret-storage' | 'interactive-login', added: number, removed: number, changed: number): void { + const kind = added > 0 && removed > 0 ? 'replacement' : added > 0 ? 'addition' : removed > 0 ? 'removal' : 'change'; + const message = `Session change: source=${source}, kind=${kind}, added=${added}, removed=${removed}, changed=${changed}.`; + if (kind === 'replacement') { + this._logger.info(message); + } else { + this._logger.trace(message); + } + } + private async checkForUpdates() { // Only the persisted sessions are reconciled against the Keychain: transient sessions do not // exist there, so they must never be diffed against it and reported as removed. @@ -601,6 +611,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid }); if (added.length || removed.length) { + this.logSessionChange('secret-storage', added.length, removed.length, 0); this._sessionChangeEmitter.fire({ added, removed, changed: [] }); } } @@ -748,6 +759,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid } await this.storeSessions(sessions); + this.logSessionChange('interactive-login', 1, removed.length, 0); this._sessionChangeEmitter.fire({ added: [session], removed, changed: [] }); this._logger.info('Login success!'); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index e2e6c3483c0080..de26e934829f79 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -443,10 +443,22 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun if (e.providerId !== defaultAccountProvider.id) { return; } - if (this.defaultAccount && e.event.removed?.some(session => session.id === this.defaultAccount?.sessionId)) { + // Providers can atomically replace a session, so only removal-only events represent an immediate sign-out. + const added = e.event.added?.length ?? 0; + const removed = e.event.removed?.length ?? 0; + const changed = e.event.changed?.length ?? 0; + const currentSessionRemoved = this.defaultAccount !== null && (e.event.removed?.some(session => session.id === this.defaultAccount?.sessionId) ?? false); + const hasReplacementCandidates = added > 0 || changed > 0; + const clearDefaultAccount = currentSessionRemoved && !hasReplacementCandidates; + const message = `[DefaultAccount] Authentication sessions changed: added=${added}, removed=${removed}, changed=${changed}, currentSessionRemoved=${currentSessionRemoved}, hasReplacementCandidates=${hasReplacementCandidates}, decision=${clearDefaultAccount ? 'clear' : 'reconcile'}`; + if (currentSessionRemoved || (added > 0 && removed > 0)) { + this.logService.info(message); + } else { + this.logService.trace(message); + } + if (clearDefaultAccount) { this.setDefaultAccount(null); } else { - this.logService.debug('[DefaultAccount] Sessions changed for default account provider, updating default account'); this.updateDefaultAccount(); } })); 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 acbd39eb3fbe4d..2f99259835650a 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -23,14 +23,14 @@ import { IRequestService } from '../../../../../platform/request/common/request. import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { AuthenticationSession, IAuthenticationExtensionsService, IAuthenticationService } from '../../../authentication/common/authentication.js'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationExtensionsService, IAuthenticationService } from '../../../authentication/common/authentication.js'; import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; import { IExtensionService } from '../../../extensions/common/extensions.js'; import { IHostService } from '../../../host/browser/host.js'; import { DefaultAccountProvider } from '../../browser/defaultAccount.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; -suite('DefaultAccountProvider managed settings', () => { +suite('DefaultAccountProvider', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const accountId = 'account'; @@ -775,11 +775,65 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('reconciles a replacement without a signed-out gap and preserves removal-only behavior', async () => { + const sessionChanges = disposables.add(new Emitter<{ providerId: string; label: string; event: AuthenticationSessionsChangeEvent }>()); + let authenticationSessions = sessions; + const provider = await createProvider( + new TestRequestService(async () => jsonResponse({ chat_enabled: true })), + {}, + {}, + '', + { + getSessions: async () => authenticationSessions, + onDidChangeSessions: sessionChanges.event, + } + ); + const observedSessionIds: Array = []; + disposables.add(provider.onDidChangeDefaultAccount(account => observedSessionIds.push(account?.sessionId ?? null))); + const replacementSession = { ...sessions[0], id: 'replacement-session', accessToken: 'replacement-token' }; + authenticationSessions = [replacementSession]; + const beforeReplacement = provider.defaultAccount?.sessionId; + const replacement = Event.toPromise(Event.filter( + provider.onDidChangeDefaultAccount, + account => account?.sessionId === replacementSession.id + )); + + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [replacementSession], removed: sessions, changed: [] }, + }); + const afterReplacementEvent = provider.defaultAccount?.sessionId; + const afterReplacement = (await replacement)?.sessionId; + + authenticationSessions = []; + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [], removed: [replacementSession], changed: [] }, + }); + + assert.deepStrictEqual({ + beforeReplacement, + afterReplacementEvent, + afterReplacement, + afterRemovalOnlyEvent: provider.defaultAccount?.sessionId, + observedSessionIds, + }, { + beforeReplacement: 'session', + afterReplacementEvent: 'session', + afterReplacement: 'replacement-session', + afterRemovalOnlyEvent: undefined, + observedSessionIds: ['replacement-session', null], + }); + }); + async function createProvider( requestService: TestRequestService, nativeManagedSettings: ManagedSettingsData = {}, fileManagedSettings: ManagedSettingsData = {}, - managedSettingsUrl = 'https://api.github.com/copilot_internal/managed_settings' + managedSettingsUrl = 'https://api.github.com/copilot_internal/managed_settings', + authenticationServiceOverrides: Partial = {}, ): Promise { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService()); @@ -792,6 +846,7 @@ suite('DefaultAccountProvider managed settings', () => { onDidChangeSessions: Event.None, onDidRegisterAuthenticationProvider: Event.None, onDidUnregisterAuthenticationProvider: Event.None, + ...authenticationServiceOverrides, }); instantiationService.stub(IAuthenticationExtensionsService, { getAccountPreference: () => undefined, From 00e9a40247e06c91750b16c07bb1be556d20aede Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 31 Aug 2026 12:10:01 -0700 Subject: [PATCH 06/21] accounts: discard stale account refreshes Do not publish an asynchronous refresh result after the current session changes. This keeps a removal-only sign-out final when replacement reconciliation was already in flight. Refs #333197 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../accounts/browser/defaultAccount.ts | 5 ++ .../test/browser/defaultAccount.test.ts | 59 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index de26e934829f79..8e9589e88f8aa0 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -573,8 +573,13 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun } private async doUpdateDefaultAccount(options?: IDefaultAccountRefreshOptions): Promise { + const currentSessionId = this.defaultAccount?.sessionId; try { const defaultAccount = await this.fetchDefaultAccount(options); + if (currentSessionId && this.defaultAccount?.sessionId !== currentSessionId) { + this.logService.info('[DefaultAccount] Discarding default account update because the current session changed while the update was in progress'); + return; + } this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { 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 2f99259835650a..36ee3750059ffe 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; @@ -828,6 +828,63 @@ suite('DefaultAccountProvider', () => { }); }); + test('does not restore a removed session from an in-flight replacement refresh', async () => { + const sessionChanges = disposables.add(new Emitter<{ providerId: string; label: string; event: AuthenticationSessionsChangeEvent }>()); + const refreshStarted = new DeferredPromise(); + const releaseRefresh = new DeferredPromise(); + let authenticationSessions = sessions; + let blockRefresh = false; + const provider = await createProvider( + new TestRequestService(async options => { + if (blockRefresh && options.callSite === 'defaultAccount.entitlements') { + refreshStarted.complete(); + return releaseRefresh.p; + } + return jsonResponse({ chat_enabled: true }); + }), + {}, + {}, + '', + { + getSessions: async () => authenticationSessions, + onDidChangeSessions: sessionChanges.event, + } + ); + const observedSessionIds: Array = []; + disposables.add(provider.onDidChangeDefaultAccount(account => observedSessionIds.push(account?.sessionId ?? null))); + const replacementSession = { ...sessions[0], accessToken: 'replacement-token' }; + authenticationSessions = [replacementSession]; + blockRefresh = true; + + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [replacementSession], removed: sessions, changed: [] }, + }); + const replacementRefresh = provider.refresh({ forceRefresh: true }); + await refreshStarted.p; + + authenticationSessions = []; + sessionChanges.fire({ + providerId: 'github', + label: 'GitHub', + event: { added: [], removed: [replacementSession], changed: [] }, + }); + const afterRemoval = provider.defaultAccount?.sessionId; + releaseRefresh.complete(jsonResponse({ chat_enabled: false })); + await replacementRefresh; + + assert.deepStrictEqual({ + afterRemoval, + afterBlockedRefresh: provider.defaultAccount?.sessionId, + observedSessionIds, + }, { + afterRemoval: undefined, + afterBlockedRefresh: undefined, + observedSessionIds: [null], + }); + }); + async function createProvider( requestService: TestRequestService, nativeManagedSettings: ManagedSettingsData = {}, From 3596f0a6713ced8300ca1f5123a28985dd333b0c Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 31 Aug 2026 15:36:44 -0700 Subject: [PATCH 07/21] accounts: run default account tests in web sessions Model the Agents web workbench in the account test fixture so browser runs do not skip provider initialization and session-change listener registration. Refs #333197 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../services/accounts/test/browser/defaultAccount.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 36ee3750059ffe..cc09c8026cf600 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -915,7 +915,7 @@ suite('DefaultAccountProvider', () => { instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IWorkbenchEnvironmentService, { remoteAuthority: undefined, - isSessionsWindow: false, + isSessionsWindow: true, }); instantiationService.stub(IProductService, { ...TestProductService, From e51f3923da60767e8b60eea292a780208a97d2be Mon Sep 17 00:00:00 2001 From: vritant24 Date: Mon, 31 Aug 2026 15:49:34 -0700 Subject: [PATCH 08/21] accounts: test session replacement in editor windows Model a local editor in Electron and a remote editor in browser tests so the shared default-account replacement behavior is covered outside the Agents window as well. Refs #333197 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../services/accounts/test/browser/defaultAccount.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 cc09c8026cf600..af5d719b5ba990 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -914,8 +915,8 @@ suite('DefaultAccountProvider', () => { instantiationService.stub(IRequestService, requestService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IWorkbenchEnvironmentService, { - remoteAuthority: undefined, - isSessionsWindow: true, + remoteAuthority: isWeb ? 'test-remote' : undefined, + isSessionsWindow: false, }); instantiationService.stub(IProductService, { ...TestProductService, From d2273f76656520b2be4ad9a1d7b7d352d2c79f98 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:03:32 -0700 Subject: [PATCH 09/21] agentHost: normalize secondary assignment context telemetry (#333661) * agentHost: normalize secondary assignment context telemetry Promote the SDK wire property as secondary.assignmentcontext and omit the original snake-case field from forwarded workbench events. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: avoid copying forwarded telemetry properties Remove the raw SDK assignment context from the newly-created telemetry payload instead of cloning the SDK property bag first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotGitHubTelemetryForwarder.ts | 1 + .../node/copilot/copilotSecondaryAssignmentContext.ts | 4 ++-- src/vs/platform/agentHost/test/node/copilotAgent.test.ts | 2 +- .../test/node/copilotGitHubTelemetryForwarder.test.ts | 2 +- .../test/node/copilotSecondaryAssignmentContext.test.ts | 6 +++--- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index af8c493caa82de..7669c43571dca0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -225,6 +225,7 @@ export class CopilotGitHubTelemetryForwarder { kind: event.kind, restricted: notification.restricted, }; + delete data.secondary_assignment_context; if (event.kind === 'response.success' || event.kind === 'response.error') { if (agentHostTurnId) { data.turnId = agentHostTurnId; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts b/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts index f371eeaae33ab0..f2da2e33b4ad78 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSecondaryAssignmentContext.ts @@ -7,9 +7,9 @@ import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; import { isValidAssignmentContext } from '../../../telemetry/common/assignmentContext.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -const SECONDARY_ASSIGNMENT_CONTEXT_PROPERTY = 'secondary_assignment_context'; +const SECONDARY_ASSIGNMENT_CONTEXT_PROPERTY = 'secondary.assignmentcontext'; -// __GDPR__COMMON__ "secondary_assignment_context" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Secondary experiment assignment context assigned by CAPI during Copilot model calls." } +// __GDPR__COMMON__ "secondary.assignmentcontext" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Secondary experiment assignment context assigned by CAPI during Copilot model calls." } export class CopilotSecondaryAssignmentContext { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 08073d69d59a83..dadf9b8b01de10 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1167,7 +1167,7 @@ suite('CopilotAgent', () => { }); assert.deepStrictEqual(telemetryService.experimentProperties, { - secondary_assignment_context: 'secondary:1', + 'secondary.assignmentcontext': 'secondary:1', }); } finally { await disposeAgent(agent); diff --git a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts index 58431480993102..2a2475cc861ee8 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts @@ -50,7 +50,7 @@ suite('CopilotGitHubTelemetryForwarder', () => { kind: 'tool_call_executed', created_at: '2026-07-10T12:00:00Z', model_call_id: 'model-call', - properties: { tool_name: 'grep' }, + properties: { tool_name: 'grep', secondary_assignment_context: 'secondary:1' }, metrics: { duration_ms: 42 }, exp_assignment_context: 'experiment', features: { featureA: 'enabled' }, diff --git a/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts b/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts index b2999320c8d9b8..d4de07adf63503 100644 --- a/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts @@ -39,8 +39,8 @@ suite('CopilotSecondaryAssignmentContext', () => { context.update(notification('secondary:2')); assert.deepStrictEqual(telemetryService.experimentProperties, [ - { name: 'secondary_assignment_context', value: 'secondary:1' }, - { name: 'secondary_assignment_context', value: 'secondary:2' }, + { name: 'secondary.assignmentcontext', value: 'secondary:1' }, + { name: 'secondary.assignmentcontext', value: 'secondary:2' }, ]); }); @@ -52,7 +52,7 @@ suite('CopilotSecondaryAssignmentContext', () => { context.update(notification('secondary:1')); assert.deepStrictEqual(telemetryService.experimentProperties, [ - { name: 'secondary_assignment_context', value: 'secondary:1' }, + { name: 'secondary.assignmentcontext', value: 'secondary:1' }, ]); }); }); From 2b82959524219817ff6eab5d093b45505732fdc7 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:09:37 -0700 Subject: [PATCH 10/21] Revert "chore: npm audit fix (#333636)" This reverts commit 5886d26498c71fbe4fe0ce10148cc5f9cbd8345e. --- .../agent-sdk/agents/claude/package-lock.json | 56 +++++++------------ package-lock.json | 28 +++++----- 2 files changed, 35 insertions(+), 49 deletions(-) diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index c1c8809640869f..015e6be556186d 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -182,9 +182,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "peer": true, "engines": { @@ -292,21 +292,21 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^1.0.5", "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" @@ -316,20 +316,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -659,9 +645,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -806,9 +792,9 @@ } }, "node_modules/hono": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", - "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "peer": true, "engines": { @@ -861,9 +847,9 @@ "peer": true }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "peer": true, "engines": { diff --git a/package-lock.json b/package-lock.json index d2855e0158030d..e6ae358481845e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2127,13 +2127,13 @@ "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, "node_modules/@microsoft/dev-tunnels-connections": { - "version": "1.3.55", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.55.tgz", - "integrity": "sha512-Vr60/UrmSJipuZ9hrL6VjKefoNpkuESfpCO7omLy9GNQXY9aE6uieUxtXLNa+79+T5f/wLGqiF9rvAGh2S/KdQ==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.50.tgz", + "integrity": "sha512-L3vUE7jiW4tzx1D+sEsuCW5UKK3CjYFIxOtjZF/MN8ZCM2a2MVtIijctjU+/Y6Gi+ohvrOZKoqSZFRD12bpAgA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.55", - "@microsoft/dev-tunnels-management": "1.3.55", + "@microsoft/dev-tunnels-contracts": "1.3.50", + "@microsoft/dev-tunnels-management": "1.3.50", "await-semaphore": "^0.1.3", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -2143,8 +2143,8 @@ "websocket": "^1.0.28" }, "peerDependencies": { - "@microsoft/dev-tunnels-ssh": "^3.12.42", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.42" + "@microsoft/dev-tunnels-ssh": "^3.12.29", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.29" } }, "node_modules/@microsoft/dev-tunnels-connections/node_modules/vscode-jsonrpc": { @@ -2157,9 +2157,9 @@ } }, "node_modules/@microsoft/dev-tunnels-contracts": { - "version": "1.3.55", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.55.tgz", - "integrity": "sha512-1qms2x+DKIYTxWIQaISWCtiFzvHsMzOqu7M1c3yoxFB7PG9KNfgS5MS7Y0Ftazckwm0M+61mUM2SueBTK5SsDQ==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.50.tgz", + "integrity": "sha512-R4G/h939dL3UOui/69cKmRNZVf+3IpO6bMWxOgt4NYjF2IvWaSWBoc+u2AdZuZi+0ZHatWXvjoCDLNrV4pUyLQ==", "license": "MIT", "dependencies": { "buffer": "^5.2.1", @@ -2177,12 +2177,12 @@ } }, "node_modules/@microsoft/dev-tunnels-management": { - "version": "1.3.55", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.55.tgz", - "integrity": "sha512-qUHuRH+3ZzBwmOmUeW/38EC09yT8OfcSgEOihnTn/uqu9UFiJSssg4o3R9cIpEFJgRy3LrY48HV3XFJrqrd7KA==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.50.tgz", + "integrity": "sha512-sWK0CrBcmiNyeb3HztocR+Gd5ROfKjyixhxfSJ+TlGIj9Y4i3DggBDFZeaKGql5LbJN5+v13tOggF2vhn8OEfA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.55", + "@microsoft/dev-tunnels-contracts": "1.3.50", "axios": "^1.8.4", "buffer": "^5.2.1", "debug": "^4.1.1", From b590b2fdc930c7f930299d858141d983f4388715 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:15:53 -0700 Subject: [PATCH 11/21] agentHost: correlate subagent response telemetry (#333618) * agentHost: correlate subagent response telemetry Correlate forwarded Copilot SDK response events with logical subagent turns, including the runtime ordering where response telemetry precedes the matching assistant message. Preserve the owning session interaction mode on subagent turn telemetry.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: bound response telemetry correlations Discard late model-call mappings after root or timeout fallback and cover both event orderings with real Copilot session state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: extract model call turn correlation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: avoid uncorrelated response turn IDs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: fix response telemetry test typing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 3 + .../agentHost/node/agentSideEffects.ts | 13 +-- .../agentHost/node/copilot/copilotAgent.ts | 33 +++++++- .../node/copilot/copilotAgentSession.ts | 2 + .../node/copilot/modelCallTurnCorrelation.ts | 69 +++++++++++++++ .../test/node/agentHostTurnTelemetry.test.ts | 25 ++++-- .../agentHost/test/node/copilotAgent.test.ts | 72 ++++++++++------ .../platform/agentHost/test/node/mockAgent.ts | 5 ++ .../node/modelCallTurnCorrelation.test.ts | 83 +++++++++++++++++++ 9 files changed, 264 insertions(+), 41 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts create mode 100644 src/vs/platform/agentHost/test/node/modelCallTurnCorrelation.test.ts diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 2efbd02face437..f1d9ee618eed1b 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1144,6 +1144,9 @@ export interface IAgent { /** Return bounded diagnostics for an in-flight turn when supported. */ getTurnDiagnosticSnapshot?(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot | undefined; + /** Record the host-remapped turn for a completed provider model call. */ + recordModelCallTurnCorrelation?(chat: URI, modelCallId: string, turnId: string): void; + // ---- Active clients and interaction ------------------------------------ /** Get or create one client's contribution handle for an exact chat. */ diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 60eed5bf1c4ab8..e1773893947b0c 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -620,7 +620,7 @@ export class AgentSideEffects extends Disposable { const subTurnId = this._stateManager.getActiveTurnId(subagentSession.chatUri); if (subTurnId) { if (signal.kind === 'model_call_completed') { - this._recordModelCallCompleted(signal, subagentSession.chatUri, subTurnId, 'remap'); + this._recordModelCallCompleted(agent, signal, subagentSession.chatUri, subTurnId, 'remap'); } else { this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, 'remap', agent); } @@ -670,7 +670,7 @@ export class AgentSideEffects extends Disposable { const turnId = this._stateManager.getActiveTurnId(sessionKey); if (turnId) { if (signal.kind === 'model_call_completed') { - this._recordModelCallCompleted(signal, sessionKey, turnId, 'preserve'); + this._recordModelCallCompleted(agent, signal, sessionKey, turnId, 'preserve'); } else { this._dispatchActionForSession(signal, sessionKey, turnId, 'preserve', agent); } @@ -893,11 +893,12 @@ export class AgentSideEffects extends Disposable { return `${chat}\0${turnId}`; } - private _recordModelCallCompleted(signal: IAgentModelCallCompletedSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting): void { + private _recordModelCallCompleted(agent: IAgent, signal: IAgentModelCallCompletedSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting): void { if (signal.turnId !== turnId && turnIdRouting === 'preserve') { this._logService.trace(`[AgentSideEffects] Dropping stale model_call_completed for ${sessionKey}: producerTurnId=${signal.turnId}, activeTurnId=${turnId}`); return; } + agent.recordModelCallTurnCorrelation?.(signal.resource, signal.modelCallId, turnId); this._turnTracker.modelCallCompleted(sessionKey, turnId, signal.modelCallId); } @@ -994,7 +995,8 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(parentSessionUri); if (agent) { - this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(parentSessionUri)?.config); + this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } @@ -1069,7 +1071,8 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(subagent.sessionUri); if (agent) { - this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(subagent.sessionUri)?.config); + this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } this._subagentChats.set({ ...subagent, immediateParentChatUri: correlatedParentChatUri, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 060ab9762ae808..a2e689442aac85 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1652,7 +1652,7 @@ export class CopilotAgent extends Disposable implements IAgent { const additionalProperties = { initiatorClientType: this._clientTypeForTelemetry(notification.sessionId) }; const router = this._githubTelemetryRouter; if (!router?.isTarget(notification)) { - this._gitHubTelemetryForwarder.forward(notification, this._turnIdForTelemetry(notification.sessionId)); + await this._forwardGitHubTelemetry(notification); return; } if (!notification.restricted) { @@ -1691,8 +1691,31 @@ export class CopilotAgent extends Disposable implements IAgent { : AgentHostClientType.Unknown; } - private _turnIdForTelemetry(sdkSessionId: string | undefined): string | undefined { - return sdkSessionId ? this._findSessionBySdkId(sdkSessionId)?.currentTurnId : undefined; + private async _forwardGitHubTelemetry(notification: GitHubTelemetryNotification): Promise { + const session = notification.sessionId ? this._findSessionBySdkId(notification.sessionId) : undefined; + if (!session) { + this._gitHubTelemetryForwarder.forward(notification); + return; + } + const fallbackTurnId = session.currentTurnId; + const event = notification.event; + if (event.kind === 'response.success' || event.kind === 'response.error') { + const modelCallId = event.properties.modelCallId ?? event.model_call_id; + if (typeof modelCallId === 'string') { + const correlatedTurnId = session.modelCallTurnCorrelation.take(modelCallId); + if (correlatedTurnId) { + this._gitHubTelemetryForwarder.forward(notification, correlatedTurnId); + return; + } + if (event.properties.initiatorType === 'agent') { + const delayedTurnId = await session.modelCallTurnCorrelation.wait(modelCallId); + this._gitHubTelemetryForwarder.forward(notification, delayedTurnId); + return; + } + session.modelCallTurnCorrelation.markResponseForwarded(modelCallId); + } + } + this._gitHubTelemetryForwarder.forward(notification, fallbackTurnId); } /** @@ -2983,6 +3006,10 @@ export class CopilotAgent extends Disposable implements IAgent { return session.getTurnDiagnosticSnapshot(turnId) ?? { state: 'missingTurn' }; } + recordModelCallTurnCorrelation(chat: URI, modelCallId: string, turnId: string): void { + this._findChatByUri(chat)?.modelCallTurnCorrelation.record(modelCallId, turnId); + } + /** Creates one exact chat backing: fresh, deferred, imported, or forked. */ private async _createChat(chat: URI, context: IAgentChatContext, options: IAgentCreateChatOptions = {}): Promise { const scope = context.configurationResource; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 99587b3fafb3d8..92df62f545f212 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -86,6 +86,7 @@ import type { ErrorInfo, ProtectedResourceMetadata } from '../../common/state/pr import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js'; import { createCopilotFailureCorrelation, reportCopilotModelCallFailure, reportCopilotSdkSessionError } from './copilotFailureTelemetry.js'; import { reportCopilotTodoStoreOperation } from './copilotTodoStoreTelemetry.js'; +import { ModelCallTurnCorrelation } from './modelCallTurnCorrelation.js'; type CopilotSdkAttachment = Required['attachments'][number]; type CopilotCommandInvocationResult = Awaited>; @@ -747,6 +748,7 @@ export class CopilotAgentSession extends Disposable { */ private readonly _parentToolCallIdsByAgentId = new Map(); private readonly _rootTurnIdBySubagentToolCallId = new Map(); + readonly modelCallTurnCorrelation = new ModelCallTurnCorrelation(); private readonly _subagentDirectUsageByToolCallId = new Map(); private readonly _lastSubagentUsageByToolCallId = new Map(); /** diff --git a/src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts b/src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts new file mode 100644 index 00000000000000..046f4f214eb3c0 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, raceTimeout } from '../../../../base/common/async.js'; +import { LRUCache } from '../../../../base/common/map.js'; + +const DEFAULT_TIMEOUT_MS = 100; +const DEFAULT_CACHE_LIMIT = 1000; + +/** Correlates model-call response telemetry with host-remapped Agent Host turns. */ +export class ModelCallTurnCorrelation { + private readonly _turnIdsByModelCallId: LRUCache; + private readonly _pendingTurnIdsByModelCallId = new Map>(); + private readonly _forwardedModelCallIdsAwaitingCorrelation: LRUCache; + private readonly _timeoutMs: number; + + constructor(options: { readonly timeoutMs?: number; readonly cacheLimit?: number } = {}) { + this._timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const cacheLimit = options.cacheLimit ?? DEFAULT_CACHE_LIMIT; + this._turnIdsByModelCallId = new LRUCache(cacheLimit); + this._forwardedModelCallIdsAwaitingCorrelation = new LRUCache(cacheLimit); + } + + record(modelCallId: string, turnId: string): void { + if (this._forwardedModelCallIdsAwaitingCorrelation.delete(modelCallId)) { + return; + } + const pending = this._pendingTurnIdsByModelCallId.get(modelCallId); + if (pending) { + this._pendingTurnIdsByModelCallId.delete(modelCallId); + pending.complete(turnId); + return; + } + this._turnIdsByModelCallId.set(modelCallId, turnId); + } + + take(modelCallId: string): string | undefined { + const turnId = this._turnIdsByModelCallId.get(modelCallId); + this._turnIdsByModelCallId.delete(modelCallId); + return turnId; + } + + markResponseForwarded(modelCallId: string): void { + this._turnIdsByModelCallId.delete(modelCallId); + this._forwardedModelCallIdsAwaitingCorrelation.set(modelCallId, true); + } + + async wait(modelCallId: string): Promise { + const existing = this.take(modelCallId); + if (existing) { + return existing; + } + if (this._forwardedModelCallIdsAwaitingCorrelation.has(modelCallId)) { + return undefined; + } + const pending = new DeferredPromise(); + this._pendingTurnIdsByModelCallId.set(modelCallId, pending); + const turnId = await raceTimeout(pending.p, this._timeoutMs); + if (this._pendingTurnIdsByModelCallId.get(modelCallId) === pending) { + this._pendingTurnIdsByModelCallId.delete(modelCallId); + } + if (turnId === undefined) { + this.markResponseForwarded(modelCallId); + } + return turnId; + } +} diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index ebce254637842b..006a2871ef2412 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -387,6 +387,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('attributes subagent model responses only to the subagent turn', () => { setupSession(); + setSessionConfig({ mode: 'plan' }); startTurn('turn-parent'); const subagentChatUri = buildSubagentChatUri(sessionUri, 'call-subagent'); stateManager.addChat(sessionKey, subagentChatUri); @@ -417,13 +418,23 @@ suite('AgentSideEffects — turn tracker telemetry', () => { fire({ type: ActionType.ChatTurnComplete, turnId: subagentTurnId, duration: 1000 }, subagentChatUri); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-parent', duration: 1000 }); - assert.deepStrictEqual(completedEvents().map(event => { - const data = event.data as Record; - return { isSubagentSession: data.isSubagentSession, modelCallCount: data.modelCallCount }; - }), [ - { isSubagentSession: true, modelCallCount: 1 }, - { isSubagentSession: false, modelCallCount: 0 }, - ]); + assert.deepStrictEqual({ + completed: completedEvents().map(event => { + const data = event.data as Record; + return { isSubagentSession: data.isSubagentSession, interactionMode: data.interactionMode, modelCallCount: data.modelCallCount }; + }), + correlations: agent.modelCallTurnCorrelationCalls.map(({ chat, ...correlation }) => ({ chat: chat.toString(), ...correlation })), + }, { + completed: [ + { isSubagentSession: true, interactionMode: 'plan', modelCallCount: 1 }, + { isSubagentSession: false, interactionMode: 'plan', modelCallCount: 0 }, + ], + correlations: [{ + chat: defaultChatUri, + modelCallId: 'subagent-model-call', + turnId: subagentTurnId, + }], + }); }); test('correlates first-level and nested subagent turns with their immediate parent', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index dadf9b8b01de10..d4c6c296a3df7f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1214,47 +1214,67 @@ suite('CopilotAgent', () => { const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; assert.ok(forward); - chatEntriesBySdkId(agent).set('active-session', { - chatSession: { currentTurnId: 'turn-1' } as CopilotAgentSession, - dispose() { }, - }); - chatEntriesBySdkId(agent).set('second-active-session', { - chatSession: { currentTurnId: 'turn-2' } as CopilotAgentSession, - dispose() { }, + const subagentCorrelation = new DeferredPromise(); + const forwardedModelCallIds: string[] = []; + const activeSession: Pick & { + modelCallTurnCorrelation: Pick; + } = { + currentTurnId: 'turn-1', + modelCallTurnCorrelation: { + take: () => undefined, + wait: modelCallId => modelCallId === 'unresolved-model-call' ? Promise.resolve(undefined) : subagentCorrelation.p, + markResponseForwarded: modelCallId => forwardedModelCallIds.push(modelCallId), + }, + }; + setLiveChatStub(agent, 'active-session', activeSession); + setLiveChatStub(agent, 'second-active-session', { + currentTurnId: 'turn-2', }); - chatEntriesBySdkId(agent).set('idle-session', { - chatSession: { currentTurnId: undefined } as CopilotAgentSession, - dispose() { }, + setLiveChatStub(agent, 'idle-session', { + currentTurnId: undefined, }); - const notification = (sessionId: string, turnId: string): GitHubTelemetryNotification => ({ + const notification = (sessionId: string, turnId: string, modelCallId?: string, initiatorType?: string): GitHubTelemetryNotification => ({ sessionId, restricted: false, event: { kind: 'response.success', - properties: { turnId }, + properties: { turnId, ...(modelCallId ? { modelCallId } : {}), ...(initiatorType ? { initiatorType } : {}) }, metrics: {}, }, }); - await forward(notification('active-session', 'runtime-active')); + await forward(notification('active-session', 'runtime-active', 'root-model-call', 'user')); + await forward(notification('active-session', 'runtime-subagent', 'subagent-model-call', 'agent')); + subagentCorrelation.complete('subagent-turn'); + await timeout(0); + await forward(notification('active-session', 'runtime-unresolved', 'unresolved-model-call', 'agent')); + await timeout(0); await forward(notification('second-active-session', 'runtime-second-active')); await forward(notification('active-session', 'runtime-active-again')); await forward(notification('idle-session', 'runtime-idle')); await forward(notification('unknown-session', 'runtime-unknown')); - assert.deepStrictEqual(telemetryService.events.map(event => { - const data = event.data as Record; - return event.eventName === 'agentHost.copilotClientStartup' - ? { eventName: event.eventName, outcome: data.outcome, durationMs: typeof data.durationMs, attemptNumber: data.attemptNumber } - : { eventName: event.eventName, sessionId: data.sdk_session_id, turnId: data.turnId }; - }), [ - { eventName: 'agentHost.copilotClientStartup', outcome: 'success', durationMs: 'number', attemptNumber: 1 }, - { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, - { eventName: 'copilotSdk/response.success', sessionId: 'second-active-session', turnId: 'turn-2' }, - { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, - { eventName: 'copilotSdk/response.success', sessionId: 'idle-session', turnId: undefined }, - { eventName: 'copilotSdk/response.success', sessionId: 'unknown-session', turnId: undefined }, - ]); + assert.deepStrictEqual({ + events: telemetryService.events.map(event => { + const data = event.data as Record; + return event.eventName === 'agentHost.copilotClientStartup' + ? { eventName: event.eventName, outcome: data.outcome, durationMs: typeof data.durationMs, attemptNumber: data.attemptNumber } + : { eventName: event.eventName, sessionId: data.sdk_session_id, turnId: data.turnId }; + }), + forwardedModelCallIds, + }, { + events: [ + { eventName: 'agentHost.copilotClientStartup', outcome: 'success', durationMs: 'number', attemptNumber: 1 }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'subagent-turn' }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: undefined }, + { eventName: 'copilotSdk/response.success', sessionId: 'second-active-session', turnId: 'turn-2' }, + { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1' }, + { eventName: 'copilotSdk/response.success', sessionId: 'idle-session', turnId: undefined }, + { eventName: 'copilotSdk/response.success', sessionId: 'unknown-session', turnId: undefined }, + ], + forwardedModelCallIds: ['root-model-call'], + }); } finally { await disposeAgent(agent); } diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 16813579b99f4a..b888346e968e7e 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -60,6 +60,10 @@ export class MockAgent implements IAgent { readonly onDidChangeChatData = Event.None; readonly onDidSpawnChat = Event.None; getTurnDiagnosticSnapshot?: IAgent['getTurnDiagnosticSnapshot']; + + recordModelCallTurnCorrelation(chat: URI, modelCallId: string, turnId: string): void { + this.modelCallTurnCorrelationCalls.push({ chat, modelCallId, turnId }); + } private readonly _onDidSendMessage = new Emitter(); readonly onDidSendMessage = this._onDidSendMessage.event; private readonly _models = observableValue(this, []); @@ -85,6 +89,7 @@ export class MockAgent implements IAgent { readonly setClientCustomizationsCalls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; readonly setClientToolsCalls: { clientId: string; tools: readonly ToolDefinition[] }[] = []; readonly removeActiveClientCalls: { chat: URI; clientId: string }[] = []; + readonly modelCallTurnCorrelationCalls: { chat: URI; modelCallId: string; turnId: string }[] = []; /** * Every host-supplied {@link IAgentChatContext} this agent was handed, * keyed by the boundary it arrived at. Lets shared tests assert that Agent diff --git a/src/vs/platform/agentHost/test/node/modelCallTurnCorrelation.test.ts b/src/vs/platform/agentHost/test/node/modelCallTurnCorrelation.test.ts new file mode 100644 index 00000000000000..17f24ed6a830bb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/modelCallTurnCorrelation.test.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ModelCallTurnCorrelation } from '../../node/copilot/modelCallTurnCorrelation.js'; + +suite('ModelCallTurnCorrelation', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns a correlation recorded before response telemetry', () => { + const correlation = new ModelCallTurnCorrelation(); + correlation.record('model-call-1', 'turn-1'); + + assert.deepStrictEqual({ + correlation: correlation.take('model-call-1'), + remaining: correlation.take('model-call-1'), + }, { + correlation: 'turn-1', + remaining: undefined, + }); + }); + + test('resolves response telemetry waiting for a correlation', async () => { + const correlation = new ModelCallTurnCorrelation(); + const pending = correlation.wait('model-call-1'); + + correlation.record('model-call-1', 'turn-1'); + + assert.deepStrictEqual({ + correlation: await pending, + remaining: correlation.take('model-call-1'), + }, { + correlation: 'turn-1', + remaining: undefined, + }); + }); + + test('discards correlations recorded after response fallback', async () => { + const correlation = new ModelCallTurnCorrelation({ timeoutMs: 0 }); + + correlation.markResponseForwarded('immediate-model-call'); + correlation.record('immediate-model-call', 'immediate-turn'); + const timedOut = await correlation.wait('timed-out-model-call'); + correlation.record('timed-out-model-call', 'late-turn'); + + assert.deepStrictEqual({ + immediate: correlation.take('immediate-model-call'), + timedOut, + late: correlation.take('timed-out-model-call'), + }, { + immediate: undefined, + timedOut: undefined, + late: undefined, + }); + }); + + test('bounds unmatched correlations and forwarded-response markers', () => { + const recordedCorrelations = new ModelCallTurnCorrelation({ cacheLimit: 2 }); + const forwardedCorrelations = new ModelCallTurnCorrelation({ cacheLimit: 2 }); + + for (let i = 1; i <= 3; i++) { + recordedCorrelations.record(`recorded-${i}`, `turn-${i}`); + forwardedCorrelations.markResponseForwarded(`forwarded-${i}`); + } + forwardedCorrelations.record('forwarded-1', 'late-turn'); + forwardedCorrelations.record('forwarded-3', 'discarded-turn'); + + assert.deepStrictEqual({ + evictedCorrelation: recordedCorrelations.take('recorded-1'), + retainedCorrelations: [recordedCorrelations.take('recorded-2'), recordedCorrelations.take('recorded-3')], + evictedMarkerAllowsCorrelation: forwardedCorrelations.take('forwarded-1'), + retainedMarkerDiscardsCorrelation: forwardedCorrelations.take('forwarded-3'), + }, { + evictedCorrelation: undefined, + retainedCorrelations: ['turn-2', 'turn-3'], + evictedMarkerAllowsCorrelation: 'late-turn', + retainedMarkerDiscardsCorrelation: undefined, + }); + }); +}); From 9a754577e07fd950d724afedf9e9f5e0530a6efa Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Mon, 31 Aug 2026 16:28:04 -0700 Subject: [PATCH 12/21] agentHost: Re-enable GPT tool search (#333657) agentHost: re-enable GPT tool search The bundled Copilot runtime now includes the parallel tool-call fix, so restore tool search for GPT-5.4, GPT-5.5, and GPT-5.6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/toolSearchDeferral.ts | 13 +++++-------- .../agentHost/test/node/toolSearchDeferral.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts index 21cb4023a8f193..d9426746d6b21f 100644 --- a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts +++ b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isGpt56Model } from './modelIdentifiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; export { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -18,20 +19,16 @@ export const NON_DEFERRED_CLIENT_TOOL_NAMES: ReadonlySet = new Set { } }); - test('temporarily rejects OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { + test('supports OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { for (const id of ['gpt-5.4', 'gpt-5.5', 'gpt-5-4', 'gpt-5-5', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { - assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); + assert.strictEqual(agentHostModelSupportsToolSearch(id), true, id); } }); From 735c7376c1febaa709374e830fa9a2f0566c7092 Mon Sep 17 00:00:00 2001 From: Dileep Yavanmandha <52841896+dileepyavan@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:56:00 -0700 Subject: [PATCH 13/21] Fix WSL resource links in Agent Host chat responses (#333635) * changes Signed-off-by: Dileep Yavanmandha * Fix Agent Host file link hover labels Format default Markdown link hovers with the host-aware label service while preserving navigation targets and custom titles. Add coverage for WSL paths, Windows host formatting, and existing link behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * changes Signed-off-by: Dileep Yavanmandha --------- Signed-off-by: Dileep Yavanmandha Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionHandler.ts | 2 +- .../agentHost/stateToProgressAdapter.ts | 7 +- .../widget/chatContentMarkdownRenderer.ts | 10 ++- .../agentHostChatContribution.test.ts | 86 ++++++++++++++++++- .../widget/chatMarkdownRenderer.test.ts | 74 +++++++++++++++- .../chat/chatAgentMerge.fixture.ts | 4 + .../chat/chatAgentMergeNotice.fixture.ts | 5 ++ .../chat/chatProgressContentPart.fixture.ts | 5 ++ 8 files changed, 187 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 6a36cfed66eb58..25d243da8e0a4d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -6811,7 +6811,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } resolveChatResponseUri(_sessionResource: URI, href: string, _kind: 'link' | 'image'): string { - return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority); + return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority, this._config.connection.resourceUris); } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 405479cde338eb..048f3e33f9c69a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -2126,7 +2126,7 @@ function normalizeFileUriSelection(uri: URI, href: string): URI { } /** Wraps an absolute path or internal URI target for the owning Agent Host connection. */ -export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string): string { +export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority)): string { let parsed = parseAbsoluteFileLinkTarget(href); if (!parsed) { try { @@ -2146,7 +2146,10 @@ export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: st let agentHostUri: URI; try { - agentHostUri = toAgentHostUri(parsed, connectionAuthority); + agentHostUri = resourceUris.fromAgentHost(parsed); + if (parsed.scheme !== Schemas.file && isEqual(agentHostUri, parsed)) { + agentHostUri = toAgentHostUri(parsed, connectionAuthority); + } } catch { return href; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts index fb796e975274a7..e047a9cc6107e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.ts @@ -9,6 +9,8 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { type MarkedExtension } from '../../../../../base/common/marked/marked.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRenderer, IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -124,6 +126,7 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer { @IConfigurationService configurationService: IConfigurationService, @IHoverService private readonly hoverService: IHoverService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @ILabelService private readonly labelService: ILabelService, ) { } render(markdown: IMarkdownString, options?: MarkdownRenderOptions, outElement?: HTMLElement): IRenderedMarkdown { @@ -162,7 +165,12 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer { // eslint-disable-next-line no-restricted-syntax result.element.querySelectorAll('a').forEach((element) => { if (element.title) { - const title = element.title; + let title = element.title; + if (title === element.dataset.href && title.startsWith(`${AGENT_HOST_SCHEME}:`)) { + const uri = URI.parse(title); + const label = this.labelService.getUriLabel(uri); + title = uri.fragment ? `${label}#${uri.fragment}` : label; + } element.title = ''; store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, title)); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index b471cf3b72e1aa..b3f7bd95e3c636 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -26,7 +26,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; -import { createAgentHostResourceUriMapper, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; @@ -1271,6 +1271,90 @@ suite('AgentHostChatContribution', () => { }); + suite('response resource links', () => { + test('uses the WSL connection for file links despite a local session authority', () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = createAgentHostResourceUriMapper(authority); + const session = URI.parse('agent-host-copilot:/session'); + const file = URI.file('/home/user/project/src/file.ts').with({ fragment: 'L42,7' }); + const targets = [ + '/home/user/project/src/file.ts:42:7', + 'file:///home/user/project/src/file.ts#L42,7', + ]; + + assert.deepStrictEqual(targets.map(href => { + const resolved = URI.parse(sessionHandler.resolveChatResponseUri(session, href, 'link')); + return { resolved: resolved.toString(), hostUri: fromAgentHostUri(resolved).toString() }; + }), targets.map(() => ({ + resolved: toAgentHostUri(file, authority).toString(), + hostUri: file.toString(), + }))); + }); + + test('uses the WSL connection for image paths and preserves encoded path characters', () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = createAgentHostResourceUriMapper(authority); + const session = URI.parse('agent-host-copilot:/session'); + const image = URI.file('/home/user/my project/image.png'); + + assert.strictEqual( + sessionHandler.resolveChatResponseUri(session, '/home/user/my%20project/image.png', 'image'), + toAgentHostUri(image, authority).toString(), + ); + }); + + for (const host of ['local', 'WSL']) { + test(`routes ${host} internal resource links and images through the owning Agent Host`, () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const authority = host === 'local' ? 'local' : agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = host === 'local' ? identityAgentHostResourceUriMapper : createAgentHostResourceUriMapper(authority); + const session = URI.parse('agent-host-copilot:/session'); + const resources = [ + URI.parse('agenthost-content:///session/my%20result.txt?view=raw#L42,7'), + URI.parse('git-blob:///project/src/file.ts?ref=HEAD#L7'), + ]; + + assert.deepStrictEqual(resources.map(resource => { + const href = resource.toString(); + const link = sessionHandler.resolveChatResponseUri(session, href, 'link'); + return { + link, + image: sessionHandler.resolveChatResponseUri(session, href, 'image'), + unwrapped: fromAgentHostUri(URI.parse(link)).toString(), + alreadyMapped: sessionHandler.resolveChatResponseUri(session, toAgentHostUri(resource, authority).toString(), 'link'), + }; + }), resources.map(resource => ({ + link: toAgentHostUri(resource, authority).toString(), + image: toAgentHostUri(resource, authority).toString(), + unwrapped: resource.toString(), + alreadyMapped: toAgentHostUri(resource, authority).toString(), + }))); + }); + } + + test('preserves local file links and external or already mapped links', () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const session = URI.parse('agent-host-copilot:/session'); + const local = sessionHandler.resolveChatResponseUri(session, '/project/file.ts:42', 'link'); + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = createAgentHostResourceUriMapper(authority); + const mapped = toAgentHostUri(URI.file('/home/user/file.ts'), authority).toString(); + const external = 'https://example.com/file.ts'; + + assert.deepStrictEqual({ + local, + mapped: sessionHandler.resolveChatResponseUri(session, mapped, 'link'), + external: sessionHandler.resolveChatResponseUri(session, external, 'link'), + }, { + local: URI.file('/project/file.ts').with({ fragment: 'L42' }).toString(), + mapped, + external, + }); + }); + }); + // ---- Download progress notification (editor window) ----------------- suite('download progress', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts index ff7dea639c7f6c..14986b6d0a27d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownRenderer.test.ts @@ -4,9 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AGENT_HOST_LABEL_FORMATTER, agentHostAuthority, agentHostLabelFormatter, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { NullHoverService } from '../../../../../../platform/hover/test/browser/nullHoverService.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ChatContentMarkdownRenderer } from '../../../browser/widget/chatContentMarkdownRenderer.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; @@ -14,11 +21,76 @@ suite('ChatMarkdownRenderer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let testRenderer: ChatContentMarkdownRenderer; + let instantiationService: ReturnType; setup(() => { - const instantiationService = store.add(workbenchInstantiationService(undefined, store)); + instantiationService = store.add(workbenchInstantiationService(undefined, store)); testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer); }); + suite('link hovers', () => { + let setupManagedHover: sinon.SinonSpy, ReturnType>; + + setup(() => { + setupManagedHover = sinon.spy(NullHoverService.setupManagedHover); + instantiationService.stub(IHoverService, { ...NullHoverService, setupManagedHover }); + store.add(instantiationService.get(ILabelService).registerFormatter(AGENT_HOST_LABEL_FORMATTER)); + testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer); + }); + + test('shows host paths for transformed and already mapped links without changing their targets', () => { + const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); + store.add(instantiationService.get(ILabelService).registerFormatter(agentHostLabelFormatter(authority, OperatingSystem.Linux))); + const file = URI.file('/home/user/my project/a&b.ts').with({ fragment: 'L42,7' }); + const target = toAgentHostUri(file, authority).toString(); + const links = [ + { href: '/home/user/my%20project/a&b.ts:42:7', transformUri: () => target }, + { href: target, transformUri: undefined }, + ]; + + const actual = links.map(({ href, transformUri }) => { + const result = store.add(testRenderer.render(new MarkdownString(`[file](${href})`), { transformUri })); + const link = result.element.querySelector('a'); + return { + hover: setupManagedHover.lastCall.args[2], + target: link?.dataset.href, + text: link?.textContent, + nativeTitle: link?.title, + }; + }); + + assert.deepStrictEqual(actual, links.map(() => ({ + hover: '/home/user/my project/a&b.ts#L42,7', + target, + text: 'file', + nativeTitle: '', + }))); + }); + + test('uses the remote host operating system for path formatting', () => { + const labelService = instantiationService.get(ILabelService); + store.add(labelService.registerFormatter(agentHostLabelFormatter('windows-host', OperatingSystem.Windows))); + const target = toAgentHostUri(URI.file('C:/my project/file.ts'), 'windows-host').toString(); + store.add(testRenderer.render(new MarkdownString(`[file](${target})`))); + + assert.strictEqual(setupManagedHover.lastCall.args[2], 'C:\\my project\\file.ts'); + }); + + test('preserves explicit titles and ordinary file, external, and command link behavior', () => { + const target = toAgentHostUri(URI.file('/home/user/file.ts'), 'remote-host').toString(); + const file = URI.file('/my project/file.ts').with({ fragment: 'L7' }); + const markdown = new MarkdownString(`[custom](${target} "Custom title") [file](${file}) [web](https://example.com/) [command](command:example)`, { isTrusted: true }); + const result = store.add(testRenderer.render(markdown)); + + assert.deepStrictEqual({ + hovers: setupManagedHover.getCalls().map(call => call.args[2]), + targets: Array.from(result.element.querySelectorAll('a'), link => link.dataset.href), + }, { + hovers: ['Custom title', `${file.fsPath}#L7`, 'https://example.com/'], + targets: [target, file.toString(), 'https://example.com/', 'command:example'], + }); + }); + }); + test('simple', async () => { const md = new MarkdownString('a'); const result = store.add(testRenderer.render(md)); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts index 53e11fc2848b83..28fa7ebc98c9a6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts @@ -11,6 +11,7 @@ import { buildAgentMergePrompt, IAgentMergePromptSummary, parseAgentMergePrompt import { CommandsRegistry, 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 { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; import { ChatAgentMergeContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.js'; @@ -178,6 +179,9 @@ function renderAgentMerge({ container, disposableStore, theme }: ComponentFixtur const instantiationService = createEditorServices(disposableStore, { colorTheme: theme, additionalServices: (reg) => { + reg.defineInstance(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { return uri.path; } + }()); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(ICommandService, commandService); }, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts index 714c5161665e08..ada77185c037f8 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice } from '../../../../../platform/agentHost/common/agentMerge.js'; import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { systemNotificationToChatPart } from '../../../../contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.js'; import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; @@ -35,6 +37,9 @@ function renderNotice(context: ComponentFixtureContext, content: string, kind: A const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { + reg.defineInstance(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { return uri.path; } + }()); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(IChatMarkdownAnchorService, anchorService); }, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts index e954fd2b23a9aa..8536d831dd8307 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatProgressContentPart.fixture.ts @@ -9,7 +9,9 @@ import { Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatProgressContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.js'; import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; @@ -68,6 +70,9 @@ function renderProgressPart( const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { + reg.defineInstance(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { return uri.path; } + }()); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(IChatMarkdownAnchorService, mockAnchorService); }, From 3d32d48cbac48fac6262899114fcb5f576009cf0 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:07:32 -0700 Subject: [PATCH 14/21] Reflect startup experiment settings served by the new assignments endpoint (#333668) * Reflect startup experiment settings served by the sign-in gated assignments endpoint * update comment * Feedback updates --- .../browser/configurationService.ts | 18 +- .../test/browser/configurationService.test.ts | 223 +++++++++++++++++- 2 files changed, 238 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/services/configuration/browser/configurationService.ts b/src/vs/workbench/services/configuration/browser/configurationService.ts index 963ac7e283cba4..44728b84e4e937 100644 --- a/src/vs/workbench/services/configuration/browser/configurationService.ts +++ b/src/vs/workbench/services/configuration/browser/configurationService.ts @@ -1352,6 +1352,7 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem private readonly processedExperimentalSettings = new Set(); private readonly autoExperimentalSettings = new Set(); + private readonly pendingStartupExperimentalSettings = new Set(); private readonly registeredExperimentalDefaults = new Map(); private readonly configurationRegistry = Registry.as(Extensions.Configuration); private readonly throttler = this._register(new Throttler()); @@ -1366,7 +1367,8 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem super(); this.throttler.queue(() => this.updateDefaults()); - this._register(workbenchAssignmentService.onDidRefetchAssignments(() => this.throttler.queue(() => this.processExperimentalSettings(this.autoExperimentalSettings, true)))); + // Re-resolve `auto` settings and any still-pending `startup` settings on each refetch. + this._register(workbenchAssignmentService.onDidRefetchAssignments(() => this.throttler.queue(() => this.processExperimentalSettings([...this.autoExperimentalSettings, ...this.pendingStartupExperimentalSettings], true)))); // When configuration is updated make sure to apply experimental configuration overrides this._register(this.configurationRegistry.onDidUpdateConfiguration(({ properties }) => this.processExperimentalSettings(properties, false))); @@ -1402,6 +1404,7 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem } this.processedExperimentalSettings.delete(property); this.autoExperimentalSettings.delete(property); + this.pendingStartupExperimentalSettings.delete(property); continue; } const defaultValueSource: ConfigurationDefaultSource | undefined = schema.defaultValueSource && !(schema.defaultValueSource instanceof Map) ? schema.defaultValueSource : undefined; @@ -1412,17 +1415,28 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem removedDefaults.push(registeredDefault); } this.processedExperimentalSettings.delete(property); + this.pendingStartupExperimentalSettings.delete(property); continue; } if (!autoRefetch && this.processedExperimentalSettings.has(property)) { continue; } this.processedExperimentalSettings.add(property); - if (schema.experiment.mode === 'auto') { + const isAutoExperiment = schema.experiment.mode === 'auto'; + if (isAutoExperiment) { this.autoExperimentalSettings.add(property); } try { const value = await this.workbenchAssignmentService.getTreatment(schema.experiment.name ?? `config.${property}`); + // Latch a `startup` value once it first resolves; keep it pending until then so a + // later (sign-in gated) value can still be applied. + if (!isAutoExperiment) { + if (isUndefined(value)) { + this.pendingStartupExperimentalSettings.add(property); + } else { + this.pendingStartupExperimentalSettings.delete(property); + } + } const registeredDefault = this.registeredExperimentalDefaults.get(property); if (this.shouldOverride(value, schema)) { if (!equals(registeredDefault?.overrides[property], value)) { diff --git a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts index 3baaf29d9b2055..6cc104950ed4cc 100644 --- a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts @@ -35,7 +35,9 @@ import { IKeybindingEditingService, KeybindingsEditingService } from '../../../k import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; import { timeout } from '../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IWorkbenchAssignmentService } from '../../../assignment/common/assignmentService.js'; +import { IExtensionService } from '../../../extensions/common/extensions.js'; import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { BrowserWorkbenchEnvironmentService, IBrowserWorkbenchEnvironmentService } from '../../../environment/browser/environmentService.js'; @@ -92,6 +94,7 @@ suite('ConfigurationDefaultOverridesContribution', () => { type TestContribution = { processedExperimentalSettings: Set; autoExperimentalSettings: Set; + pendingStartupExperimentalSettings: Set; registeredExperimentalDefaults: Map; configurationRegistry: IConfigurationRegistry; workbenchAssignmentService: { @@ -103,6 +106,7 @@ suite('ConfigurationDefaultOverridesContribution', () => { const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; contribution.processedExperimentalSettings = new Set(); contribution.autoExperimentalSettings = new Set(); + contribution.pendingStartupExperimentalSettings = new Set(); contribution.registeredExperimentalDefaults = new Map(); contribution.configurationRegistry = configurationRegistry; contribution.workbenchAssignmentService = { @@ -156,6 +160,223 @@ suite('ConfigurationDefaultOverridesContribution', () => { configurationRegistry.deregisterConfigurations([configuration]); } }); + + test('defers a startup experiment until its value first resolves, then latches it', async () => { + const startupSetting = 'test.startupExperimentalSetting'; + const startupConfiguration: IConfigurationNode = { + id: 'test.startupExperimentalSettings', + type: 'object', + properties: { + [startupSetting]: { + type: 'string', + default: 'control', + experiment: { + mode: 'startup', + name: 'testStartupExperimentalSetting' + } + } + } + }; + // The treatment is unavailable during the initial resolution (as with a sign-in gated + // assignments endpoint) and only becomes available on a later refetch. + const treatments: Record = { + testStartupExperimentalSetting: undefined, + }; + type TestContribution = { + processedExperimentalSettings: Set; + autoExperimentalSettings: Set; + pendingStartupExperimentalSettings: Set; + registeredExperimentalDefaults: Map; + configurationRegistry: IConfigurationRegistry; + workbenchAssignmentService: { + getTreatment(name: string): Promise; + }; + environmentService: { isSessionsWindow: boolean }; + processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; + }; + const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; + contribution.processedExperimentalSettings = new Set(); + contribution.autoExperimentalSettings = new Set(); + contribution.pendingStartupExperimentalSettings = new Set(); + contribution.registeredExperimentalDefaults = new Map(); + contribution.configurationRegistry = configurationRegistry; + contribution.workbenchAssignmentService = { + getTreatment: async (name: string) => treatments[name] as T | undefined, + }; + contribution.environmentService = { isSessionsWindow: false }; + configurationRegistry.registerConfiguration(startupConfiguration); + + const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; + // Mirror the refetch handler, which re-resolves only pending startup settings. + const refetch = () => contribution.processExperimentalSettings([...contribution.pendingStartupExperimentalSettings], true); + + try { + // Initial resolution: no value yet, so the default is untouched and the setting is pending. + await contribution.processExperimentalSettings([startupSetting], false); + const afterInitial = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + // The value becomes available; the next refetch resolves and latches it. + treatments.testStartupExperimentalSetting = 'treatment'; + await refetch(); + const afterResolved = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + // A later assignment change must not move a latched startup value. + treatments.testStartupExperimentalSetting = 'changed'; + await refetch(); + const afterLatched = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + assert.deepStrictEqual({ afterInitial, afterResolved, afterLatched }, { + afterInitial: { default: 'control', pending: true }, + afterResolved: { default: 'treatment', pending: false }, + afterLatched: { default: 'treatment', pending: false }, + }); + } finally { + if (contribution.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...contribution.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterConfigurations([startupConfiguration]); + } + }); + + test('does not re-resolve a startup experiment that already resolved at startup', async () => { + const startupSetting = 'test.resolvedStartupExperimentalSetting'; + const startupConfiguration: IConfigurationNode = { + id: 'test.resolvedStartupExperimentalSettings', + type: 'object', + properties: { + [startupSetting]: { + type: 'string', + default: 'control', + experiment: { + mode: 'startup', + name: 'testResolvedStartupExperimentalSetting' + } + } + } + }; + // The value is available during the initial resolution (as with the legacy endpoint, which + // resolves before sign-in). + const treatments: Record = { + testResolvedStartupExperimentalSetting: 'treatment', + }; + type TestContribution = { + processedExperimentalSettings: Set; + autoExperimentalSettings: Set; + pendingStartupExperimentalSettings: Set; + registeredExperimentalDefaults: Map; + configurationRegistry: IConfigurationRegistry; + workbenchAssignmentService: { + getTreatment(name: string): Promise; + }; + environmentService: { isSessionsWindow: boolean }; + processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; + }; + const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; + contribution.processedExperimentalSettings = new Set(); + contribution.autoExperimentalSettings = new Set(); + contribution.pendingStartupExperimentalSettings = new Set(); + contribution.registeredExperimentalDefaults = new Map(); + contribution.configurationRegistry = configurationRegistry; + contribution.workbenchAssignmentService = { + getTreatment: async (name: string) => treatments[name] as T | undefined, + }; + contribution.environmentService = { isSessionsWindow: false }; + configurationRegistry.registerConfiguration(startupConfiguration); + + const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; + // Mirror the refetch handler, which re-resolves only pending startup settings. + const refetch = () => contribution.processExperimentalSettings([...contribution.pendingStartupExperimentalSettings], true); + + try { + // Initial resolution captures the value and latches it (it is never pending). + await contribution.processExperimentalSettings([startupSetting], false); + const afterInitial = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + // A later assignment change (e.g. after sign-in) must not move an already-resolved value. + treatments.testResolvedStartupExperimentalSetting = 'changed'; + await refetch(); + const afterRefetch = { default: readDefault(), pending: contribution.pendingStartupExperimentalSettings.has(startupSetting) }; + + assert.deepStrictEqual({ afterInitial, afterRefetch }, { + afterInitial: { default: 'treatment', pending: false }, + afterRefetch: { default: 'treatment', pending: false }, + }); + } finally { + if (contribution.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...contribution.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterConfigurations([startupConfiguration]); + } + }); + + test('re-resolves pending startup settings when onDidRefetchAssignments fires', async () => { + const startupSetting = 'test.wiredStartupExperimentalSetting'; + const startupConfiguration: IConfigurationNode = { + id: 'test.wiredStartupExperimentalSettings', + type: 'object', + properties: { + [startupSetting]: { + type: 'string', + default: 'control', + experiment: { + mode: 'startup', + name: 'testWiredStartupExperimentalSetting' + } + } + } + }; + // No value at startup, so the setting stays pending until a value arrives on a refetch. + const treatments: Record = { + testWiredStartupExperimentalSetting: undefined, + }; + const onDidRefetchAssignments = new Emitter(); + + // Construct the real contribution so the constructor's `onDidRefetchAssignments` -> refetch + // wiring is exercised end to end (rather than calling processExperimentalSettings directly). + const workbenchAssignmentService = { + onDidRefetchAssignments: onDidRefetchAssignments.event, + getTreatment: async (name: string) => treatments[name], + } as unknown as IWorkbenchAssignmentService; + const extensionService = { whenInstalledExtensionsRegistered: async () => true } as unknown as IExtensionService; + const workspaceService = { reloadConfiguration: async () => { } } as unknown as WorkspaceService; + const environmentService = { isSessionsWindow: false } as unknown as IWorkbenchEnvironmentService; + + configurationRegistry.registerConfiguration(startupConfiguration); + const contribution = new ConfigurationDefaultOverridesContribution(workbenchAssignmentService, extensionService, workspaceService, environmentService, new NullLogService()); + const internals = contribution as unknown as { + pendingStartupExperimentalSettings: Set; + registeredExperimentalDefaults: Map; + }; + const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; + const waitFor = async (predicate: () => boolean) => { + for (let i = 0; i < 100 && !predicate(); i++) { + await timeout(0); + } + }; + + try { + // The constructor's initial resolution finds no value, so the setting becomes pending. + await waitFor(() => internals.pendingStartupExperimentalSettings.has(startupSetting)); + const afterInitial = { default: readDefault(), pending: internals.pendingStartupExperimentalSettings.has(startupSetting) }; + + // A value arrives; firing the event must re-resolve the pending setting through the wiring. + treatments.testWiredStartupExperimentalSetting = 'treatment'; + onDidRefetchAssignments.fire(); + await waitFor(() => readDefault() === 'treatment'); + + assert.deepStrictEqual({ afterInitial, afterRefetch: { default: readDefault(), pending: internals.pendingStartupExperimentalSettings.has(startupSetting) } }, { + afterInitial: { default: 'control', pending: true }, + afterRefetch: { default: 'treatment', pending: false }, + }); + } finally { + contribution.dispose(); + onDidRefetchAssignments.dispose(); + if (internals.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...internals.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterConfigurations([startupConfiguration]); + } + }); }); function convertToWorkspacePayload(folder: URI): ISingleFolderWorkspaceIdentifier { From 24998bd483aa6128d0004552f758b44d6264535e Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Mon, 31 Aug 2026 17:10:09 -0700 Subject: [PATCH 15/21] build: Exclude tests from CodeQL analysis (#333499) Exclude tests from CodeQL analysis Reduce the CodeQL database size so default-branch overlay databases can be cached and reused by pull request scans. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/codeql/codeql-config.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000000000..f9316cdcd7dba3 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,21 @@ +# Apply this file to CodeQL default setup with the repository property: +# github-codeql-config-file: ./.github/codeql/codeql-config.yml +paths-ignore: + # Keep directory exclusions scoped: product code imports helpers from some + # extension src/test directories. + - 'test/**' + - 'src/**/test/**' + - 'build/**/test/**' + - 'cli/tests/**' + - '.eslint-plugin-local/tests/**' + - 'extensions/*/test/**' + - 'extensions/*/tests/**' + # Keep exact suffixes: themes.test.contribution.ts ships in the product. + - '**/*.test.js' + - '**/*.test.jsx' + - '**/*.test.mjs' + - '**/*.test.cjs' + - '**/*.test.ts' + - '**/*.test.tsx' + - '**/*.test.mts' + - '**/*.test.cts' From 5ae9f515ac6568c1d9cf6504073b44a05b641147 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:15:23 -0700 Subject: [PATCH 16/21] Revert "build(deps): bump ip-address and socks in /build/npm/gyp" (#333692) Revert "build(deps): bump ip-address and socks in /build/npm/gyp (#333074)" This reverts commit 2936afe15c8d9b90846bbff1734fd566e62ce9f8. --- build/npm/gyp/package-lock.json | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 7482246e1435b1..887285c3d96c8b 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -439,11 +439,15 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "dev": true, "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, "engines": { "node": ">= 12" } @@ -484,6 +488,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -904,13 +915,13 @@ } }, "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.1.1", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { @@ -933,6 +944,13 @@ "node": ">= 14" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", From 6b606c6c85f184ce581f4d898e590a093e213ba3 Mon Sep 17 00:00:00 2001 From: Paul <8560030+pwang347@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:43:09 -0500 Subject: [PATCH 17/21] Fix skill completions in Automations (#333669) * 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 --------- Copilot-Session: 24f4e305-8042-4e9f-8061-72525532fc1b --- .../meta/agentCompletionAttachmentMeta.ts | 6 ++ .../copilotSlashCommandCompletionProvider.ts | 1 + .../test/common/agentMetaReaders.test.ts | 6 +- ...ilotSlashCommandCompletionProvider.test.ts | 6 +- .../automations/browser/automationDialog.ts | 9 +- .../browser/automationInputCompletions.ts | 87 ++++++++++++++++ .../browser/media/automationDialog.css | 4 + .../test/browser/automationDialog.test.ts | 15 ++- .../automationInputCompletions.test.ts | 98 +++++++++++++++++++ .../agentHost/agentHostSessionHandler.ts | 1 + .../chat/common/chatSessionsService.ts | 1 + .../agentHostChatContribution.test.ts | 37 +++++++ 12 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts create mode 100644 src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts diff --git a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts index 71a89343be1af6..aecec425b6f55c 100644 --- a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts @@ -42,6 +42,8 @@ export interface IAgentHostCompletionAction { export interface ICommandCompletionAttachmentMeta { /** The slash command name (without the leading `/`). */ readonly command: string; + /** Whether this text-dispatched command originated from a runtime skill. */ + readonly isSkill?: true; /** Optional human-readable description of the command. */ readonly description?: string; /** @@ -96,6 +98,7 @@ export function readCompletionAttachmentMeta(attachment: SimpleMessageAttachment return { kind: 'command', command: meta['command'], + ...(meta['isSkill'] === true ? { isSkill: true } : {}), ...(typeof meta['description'] === 'string' ? { description: meta['description'] } : {}), ...(typeof meta['argumentHint'] === 'string' ? { argumentHint: meta['argumentHint'] } : {}), ...(action ? { action } : {}), @@ -121,6 +124,9 @@ export function readCompletionAttachmentMeta(attachment: SimpleMessageAttachment */ export function toCommandCompletionAttachmentMeta(meta: ICommandCompletionAttachmentMeta): Record { const result: Record = { command: meta.command }; + if (meta.isSkill === true) { + result['isSkill'] = true; + } if (meta.description !== undefined) { result['description'] = meta.description; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 6e7fa13f72424c..400a051938316c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -221,6 +221,7 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti label: insertText, _meta: toCommandCompletionAttachmentMeta({ command: command.name, + ...(command.kind === 'skill' ? { isSkill: true } : {}), ...(description !== undefined ? { description } : {}), ...(argumentHint !== undefined ? { argumentHint } : {}) }), diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 1e1529445d0c46..43717b1f0c9745 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -248,9 +248,9 @@ suite('Agent host _meta readers', () => { assert.deepStrictEqual(cmd, { command: 'rename' }); assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmd)), { kind: 'command', command: 'rename' }); - const cmdWithHint = toCommandCompletionAttachmentMeta({ command: 'rename', argumentHint: 'New name', description: undefined }); - assert.deepStrictEqual(cmdWithHint, { command: 'rename', argumentHint: 'New name' }); - assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmdWithHint)), { kind: 'command', command: 'rename', argumentHint: 'New name' }); + const cmdWithHint = toCommandCompletionAttachmentMeta({ command: 'rename', isSkill: true, argumentHint: 'New name', description: undefined }); + assert.deepStrictEqual(cmdWithHint, { command: 'rename', isSkill: true, argumentHint: 'New name' }); + assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmdWithHint)), { kind: 'command', command: 'rename', isSkill: true, argumentHint: 'New name' }); const skill = toSkillCompletionAttachmentMeta({ uri: 'file:///s/SKILL.md', name: 'mon', displayName: 'mon', description: undefined }); assert.deepStrictEqual(skill, { uri: 'file:///s/SKILL.md', name: 'mon', displayName: 'mon' }); diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 53364ca722aede..9fbab865eeedaf 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -489,7 +489,10 @@ suite('CopilotSlashCommandCompletionProvider', () => { { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, ]); const items = await run(provider, '/'); - assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/my-skill ']); + assert.deepStrictEqual(runtimeOnly(items).map(i => ({ + insertText: i.insertText, + isSkill: i.attachment?._meta?.isSkill, + })), [{ insertText: '/my-skill ', isSkill: true }]); }); test('excludes runtime skills that match a known plugin skill (with plugin prefix)', async () => { @@ -613,6 +616,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { type: MessageAttachmentKind.Simple, meta: { command: 'my-skill', + isSkill: true, description: 'Runtime skill', argumentHint: 'do stuff', }, diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 80e2f1ebda2727..9a68ffb5b17f1f 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -15,7 +15,7 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, IObservable } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -54,6 +54,7 @@ import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/b import { AutomationIsolationModel, normalizeAutomationBranchNames } from '../common/isolationGroupModel.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from '../../chat/browser/mobile/mobileWorkspacePickerSheet.js'; +import { AutomationInputCompletions } from './automationInputCompletions.js'; const $ = DOM.$; @@ -67,7 +68,7 @@ const INTERVALS: { readonly value: AutomationInterval; readonly label: string }[ // Picker popups mount outside the dialog, so allow their focus targets through its focus trap. export function isAutomationDialogPopupTarget(relatedTarget: HTMLElement): boolean { return isMobilePickerSheetTarget(relatedTarget) || !!relatedTarget.closest( - '.context-view, .quick-input-widget, .monaco-menu-container, .monaco-hover, .monaco-hover-content' + '.context-view, .quick-input-widget, .monaco-menu-container, .monaco-hover, .monaco-hover-content, .suggest-widget' ); } @@ -998,6 +999,8 @@ export function renderForm( const promptRow = DOM.append(form, $('.automation-form-row')); DOM.append(promptRow, $('span.automation-form-label', undefined, localize('automation.form.prompt', "Prompt"))); const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); + const editorOverflowWidgetsDomNode = layoutService.getContainer(DOM.getWindow(promptHost)).appendChild($('.chat-editor-overflow.automation-dialog-editor-overflow.monaco-editor')); + disposables.add(toDisposable(() => editorOverflowWidgetsDomNode.remove())); const chatInputStyles: IChatInputStyles = { overlayBackground: 'var(--vscode-input-background)', @@ -1030,6 +1033,7 @@ export function renderForm( // reserve the default 24px margin and lay the editor out too narrow, // leaving its scrollbar floating ~24px in from the right wall. inputPartHorizontalPadding: 0, + editorOverflowWidgetsDomNode, sessionTypePickerDelegate: sessionTypeDelegate, secondaryToolbarOverflowActionHandler: (actionId, anchor) => { if (actionId === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { @@ -1115,6 +1119,7 @@ export function renderForm( ); chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); + disposables.add(scopedInstantiationService.createInstance(AutomationInputCompletions, chatInput.inputEditor)); if (initialMode) { const getUnfilteredInitialMode = () => { diff --git a/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts new file mode 100644 index 00000000000000..16f8d04077a194 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationInputCompletions.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MutableDisposable } 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 { Position } from '../../../../editor/common/core/position.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 { 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 { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export class AutomationInputCompletions extends AgentHostInputCompletionsBase { + + private readonly registration = this._register(new MutableDisposable()); + + constructor( + private readonly editor: ICodeEditor, + @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, + @IChatSessionsService chatSessionsService: IChatSessionsService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(languageFeaturesService, chatSessionsService); + + let currentScheme: string | undefined; + this._register(autorun(reader => { + const session = this.sessionsManagementService.automationSession.read(reader); + const scheme = session ? getChatSessionType(session.resource) : undefined; + if (scheme === currentScheme) { + return; + } + currentScheme = scheme; + this.registration.clear(); + if (scheme && isAgentHostTarget(scheme)) { + void this.registerForScheme(scheme); + } + })); + } + + private async registerForScheme(scheme: string): Promise { + const triggerCharacters = await this._chatSessionsService.getChatInputCompletionTriggerCharacters(scheme); + if (!triggerCharacters?.length) { + return; + } + + const session = this.sessionsManagementService.automationSession.get(); + const editorUri = this.editor.getModel()?.uri; + if (!session || getChatSessionType(session.resource) !== scheme || !editorUri) { + return; + } + + this.registration.value = this._registerProvider( + { scheme: editorUri.scheme, hasAccessToAllModels: true }, + `automationInputCompletions[${scheme}]`, + triggerCharacters, + scheme, + ); + } + + protected override _resolveContext(model: ITextModel, scheme: string): { sessionResource: URI; context: void } | undefined { + const session = this.sessionsManagementService.automationSession.get(); + if (model !== this.editor.getModel() || !session || getChatSessionType(session.resource) !== scheme) { + return undefined; + } + return { sessionResource: session.resource, context: undefined }; + } + + protected override _buildItem(position: Position, item: IChatInputCompletionItem): CompletionItem | undefined { + if (item.attachment.kind !== 'skill' && !(item.attachment.kind === 'command' && item.attachment.isSkill)) { + return undefined; + } + return { + label: { label: item.label ?? item.insertText, description: item.attachment.description }, + insertText: item.insertText, + filterText: item.insertText, + range: AutomationInputCompletions.computeRange(position, item), + documentation: item.attachment.description, + kind: CompletionItemKind.Text, + }; + } +} diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index 15cbeebce0f629..668f808f9e3b05 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -216,6 +216,10 @@ z-index: 2600 !important; } +.automation-dialog-open .automation-dialog-editor-overflow { + z-index: 2600; +} + .automation-form { display: flex; flex-direction: column; 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 94896b8994dce6..cd78db7ee38241 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -885,12 +885,21 @@ suite('Automation branch picker', () => { }); }); - test('allows focus in mobile picker sheets', () => { + test('allows focus in popups rendered outside the dialog', () => { const sheet = document.createElement('div'); sheet.classList.add('mobile-picker-sheet'); - const item = sheet.appendChild(document.createElement('button')); + const sheetItem = sheet.appendChild(document.createElement('button')); + const suggestWidget = document.createElement('div'); + suggestWidget.classList.add('suggest-widget'); + const suggestion = suggestWidget.appendChild(document.createElement('div')); - assert.strictEqual(isAutomationDialogPopupTarget(item), true); + assert.deepStrictEqual({ + sheet: isAutomationDialogPopupTarget(sheetItem), + suggestion: isAutomationDialogPopupTarget(suggestion), + }, { + sheet: true, + suggestion: true, + }); }); test('resolves a legacy model identifier to the selected concrete target', () => { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts new file mode 100644 index 00000000000000..0045511fbf78ab --- /dev/null +++ b/src/vs/sessions/contrib/automations/test/browser/automationInputCompletions.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.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 { Position } from '../../../../../editor/common/core/position.js'; +import { CompletionItemKind, CompletionTriggerKind } from '../../../../../editor/common/languages.js'; +import { LanguageFeaturesService } from '../../../../../editor/common/services/languageFeaturesService.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.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'; +import { AutomationInputCompletions } from '../../browser/automationInputCompletions.js'; + +class TestChatSessionsService extends mock() { + override async getChatInputCompletionTriggerCharacters(): Promise { + return ['/']; + } + + override async provideChatInputCompletions(_sessionResource: URI, _params: IChatInputCompletionsParams, _token: CancellationToken): Promise { + return { + items: [ + { + insertText: '/review ', + start: { lineNumber: 1, column: 1 }, + end: { lineNumber: 1, column: 2 }, + attachment: { kind: 'skill', uri: URI.file('/skills/review/SKILL.md'), description: 'Review the workspace' }, + }, + { + insertText: '/plan ', + attachment: { kind: 'command', command: 'plan', description: 'Plan a task' }, + }, + { + insertText: '/runtime-skill ', + attachment: { kind: 'command', command: 'runtime-skill', isSkill: true, description: 'Run a runtime skill' }, + }, + ], + }; + } +} + +suite('AutomationInputCompletions', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + 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 }); + 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)); + await timeout(0); + + const provider = languageFeaturesService.completionProvider.ordered(model)[0]; + const result = await provider.provideCompletionItems( + model, + new Position(1, 2), + { triggerKind: CompletionTriggerKind.TriggerCharacter, triggerCharacter: '/' }, + CancellationToken.None, + ); + + assert.deepStrictEqual(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, + }, + ]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 25d243da8e0a4d..40fe4c9191d19c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1326,6 +1326,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return this._createCompletionItem(raw, text, { kind: 'command', command: completionMeta.command, + ...(completionMeta.isSkill ? { isSkill: true } : {}), description: completionMeta.description ?? '', ...(attachment._meta !== undefined && { _meta: attachment._meta }), }, attachment.label !== raw.insertText ? attachment.label : undefined); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 680e810082a1d2..c692ca4e4c2ecf 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -562,6 +562,7 @@ export interface IChatInputCompletionResourceAttachment { export interface IChatInputCompletionCommandAttachment { readonly kind: 'command'; readonly command: string; + readonly isSkill?: true; readonly description: string; /** * Implementation-defined metadata that MUST be preserved by the diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index b3f7bd95e3c636..961659b195f34f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -14435,6 +14435,43 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(result?.items[0].insertText, ''); }); + test('preserves the runtime skill distinction on command attachments', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + (agentHostService as unknown as { completions: (p: CompletionsParams) => Promise }).completions = async () => ({ + items: [{ + insertText: '/runtime-skill ', + attachment: { + type: MessageAttachmentKind.Simple, + label: '/runtime-skill ', + _meta: { + command: 'runtime-skill', + isSkill: true, + description: 'Run a runtime skill', + }, + }, + }], + }); + + const result = await sessionHandler.provideChatInputCompletions( + URI.from({ scheme: 'agent-host-copilot', path: '/abc' }), + { text: '/', offset: 1 }, + CancellationToken.None, + ); + + assert.deepStrictEqual(result?.items[0].attachment, { + kind: 'command', + command: 'runtime-skill', + isSkill: true, + description: 'Run a runtime skill', + _meta: { + command: 'runtime-skill', + isSkill: true, + description: 'Run a runtime skill', + }, + }); + }); + test('routes untitled completions to the current opaque provisional backend', async () => { const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-completions' }); const backendSession = AgentSession.uri('copilot', 'opaque-provisional'); From 717304b0de30f62658cb6c1d430f7aa5d4c13747 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:06:15 -0700 Subject: [PATCH 18/21] input pickers responsiveness v2 (#333666) * input pickers responsiveness v2 * address comments --- src/vs/base/browser/ui/toolbar/toolbar.ts | 7 + .../test/browser/ui/toolbar/toolbar.test.ts | 36 ++++ .../contrib/chat/browser/media/chatInput.css | 38 +++- .../chat/browser/media/newChatInSession.css | 2 +- .../contrib/chat/browser/newChatInput.ts | 14 +- .../chat/test/browser/chatInput.fixture.ts | 76 ++++++++ .../chat/test/browser/chatView.test.ts | 67 +++++++ .../chat/test/browser/newChatInput.test.ts | 26 +++ .../browser/agentHostSessionConfigPicker.ts | 4 +- .../agentHostSessionConfigPicker.test.ts | 28 +++ ...gentHostClaudePermissionModePicker.test.ts | 31 ++- .../browser/mobilePermissionPicker.ts | 6 +- .../copilotChatSessions/browser/modePicker.ts | 4 +- .../browser/permissionPicker.ts | 12 +- .../test/browser/modePicker.test.ts | 27 ++- .../test/browser/permissionPicker.test.ts | 63 ++++++- .../browser/widget/input/chatInputPart.ts | 22 ++- .../widget/input/chatInputPickerActionItem.ts | 2 + .../input/chatInputPickerResponsiveLayout.ts | 55 ++++-- .../input/modelPicker/media/modelPicker.css | 42 ++++- .../modelPicker/modelPickerActionItem.ts | 33 +++- .../input/modelPicker/modelPickerWidget.ts | 42 ++++- .../chat/browser/widget/media/chat.css | 3 +- .../test/browser/widget/chatPetWidget.test.ts | 12 +- .../chatInputPickerResponsiveLayout.test.ts | 178 +++++++++++++++++- .../componentFixtures/chat/renderChatInput.ts | 11 +- .../blocks-ci-screenshots.md | 4 +- 27 files changed, 783 insertions(+), 62 deletions(-) diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 8d5e2ff6ad60d7..b1d076fd5e0614 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -523,6 +523,13 @@ export class ToolBar extends Disposable { // Hide actions from the right while (minimumWidth > containerWidth && this.actionBar.length() > 0) { + if ( + this.options.responsiveBehavior?.minItems !== undefined + && getVisiblePrimaryActionIndexes().length <= this.options.responsiveBehavior.minItems + ) { + break; + } + const index = getVisiblePrimaryActionIndexes().at(-1); if (index === undefined) { break; diff --git a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts index 95bbafbbe10f9b..17f71acf90a0b5 100644 --- a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts +++ b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts @@ -516,6 +516,42 @@ suite('ToolBar', () => { }); }); + test('keeps the configured minimum actions visible across repeated relayouts', () => { + let availableWidth = 300; + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 2, + actionMinWidth: 22, + getAvailableWidth: () => availableWidth, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 60), + })); + toolbar.setActions([ + store.add(new Action('attach', 'Attach')), + store.add(new Action('agent', 'Agent')), + store.add(new Action('model', 'Model')), + store.add(new Action('settings', 'Settings')), + ]); + + const states: string[][] = []; + for (const width of [100, 300, 100, 300, 100, 300]) { + availableWidth = width; + toolbar.relayout(); + states.push(Array.from({ length: toolbar.getItemsLength() }, (_, index) => toolbar.getItemAction(index)?.id ?? '')); + } + + assert.deepStrictEqual(states, [ + ['attach', 'agent', ToggleMenuAction.ID], + ['attach', 'agent', 'model', 'settings'], + ['attach', 'agent', ToggleMenuAction.ID], + ['attach', 'agent', 'model', 'settings'], + ['attach', 'agent', ToggleMenuAction.ID], + ['attach', 'agent', 'model', 'settings'], + ]); + }); + test('uses overflow-specific proxy actions', async () => { const runs: string[] = []; let overflowAnchor: HTMLElement | undefined; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index e9298bdf3c9c08..817cf8b88574b3 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -84,7 +84,7 @@ flex-direction: column; } -.new-chat-input-area:focus-within { +.new-chat-input-area.focused { border-color: var(--vscode-agentsChatInput-focusBorder, var(--vscode-focusBorder)); } @@ -271,12 +271,35 @@ display: none; } -/* Expanded pickers remain intrinsic; the responsive controller switches them - * to compact form instead of allowing their labels to truncate. */ -.sessions-chat-config-toolbar .monaco-action-bar .action-item:not(.compact-picker) { +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker.model-picker-item { + width: auto; + min-width: min-content; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker.model-picker-item .action-label { + width: auto; + min-width: min-content; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker.model-picker-item .model-picker-config .chat-input-picker-label { + display: inline; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item:not(.compact-picker):not(.model-picker-item) { flex-shrink: 0; } +.sessions-chat-config-toolbar .monaco-action-bar .action-item.model-picker-item:not(.compact-picker) { + flex: 0 1 auto; + min-width: min-content; +} + +.sessions-chat-config-toolbar .action-item.model-picker-item .action-label.model-picker-split { + box-sizing: border-box; + width: 100%; + min-width: 0; +} + .sessions-chat-config-toolbar .action-label { display: flex; align-items: center; @@ -302,13 +325,16 @@ font-size: var(--vscode-fontSize-label2, 11px); } -/* Expanded labels are never truncated; compact mode removes the label. */ .sessions-chat-config-toolbar .action-label .chat-input-picker-label { margin-left: 4px; + white-space: nowrap; +} + +.sessions-chat-config-toolbar .action-label:not(.model-picker-split) .chat-input-picker-label, +.sessions-chat-config-toolbar .model-picker-config .chat-input-picker-label { flex-shrink: 0; overflow: visible; text-overflow: clip; - white-space: nowrap; } /* When the picker has no leading icon (e.g. model picker), drop the icon-to-label gap. */ diff --git a/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css b/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css index 9284ea76108bba..170c6b84b37cad 100644 --- a/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css +++ b/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css @@ -43,7 +43,7 @@ var(--vscode-cornerRadius-large, 8px); } -.new-chat-in-session .new-chat-input-area:focus-within { +.new-chat-in-session .new-chat-input-area.focused { border-color: var(--vscode-agentsChatInput-focusBorder, var(--vscode-focusBorder)) !important; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index dc774b56faf3aa..a8d058a7ed0b56 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -93,7 +93,7 @@ import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../workbench/ import { registerChatInputOnboardingHosts } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputOnboardingHosts.js'; import { IChatInputNoticeHubService } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHub.js'; import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; -import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refreshChatInputStack, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refreshChatInputStack, setChatInputStackInputFocused, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; @@ -883,6 +883,12 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // The composer is a chat input, so it carries the shared focus key that // chat input keybindings such as paste as text are scoped to. const inputHasFocusKey = ChatContextKeys.inputHasFocus.bindTo(inputScopedContextKeyService); + this._register(this._editor.onDidFocusEditorText(() => { + this._setInputEditorFocused(container, true); + })); + this._register(this._editor.onDidBlurEditorText(() => { + this._setInputEditorFocused(container, false); + })); this._register(this._editor.onDidFocusEditorWidget(() => { dictationFocusKey.set(true); inputHasFocusKey.set(true); @@ -997,6 +1003,11 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation })); } + private _setInputEditorFocused(container: HTMLElement, focused: boolean): void { + container.classList.toggle('focused', focused); + setChatInputStackInputFocused(container, focused); + } + /** * The input is monospace only while a terminal command is being composed: * the attached session advertises a prefix AND the current input begins with @@ -1122,6 +1133,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } items.push({ element, + canShrink: configToolbar.getItemAction(index)?.id === 'sessions.modelPicker', isCompact: () => element.classList.contains('compact-picker'), setCompact: (compact: boolean) => { element.classList.toggle('compact-picker', compact); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts index 31361f0c092d67..7f30a32c3d6ddb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts @@ -5,6 +5,9 @@ import { renderChatInput } from '../../../../../workbench/test/browser/componentFixtures/chat/renderChatInput.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ChatAgentLocation } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; // Loaded here (rather than in the workbench-layer fixture) so the // `.interactive-input-part` padding (32px each side) that the `isSessionsWindow` @@ -27,6 +30,42 @@ function sessionsWindowContext(context: ComponentFixtureContext): ComponentFixtu return { ...context, container: sessionsPart }; } +const responsiveModel: ILanguageModelChatMetadataAndIdentifier = { + identifier: 'openai-gpt-5.6-luna-responsive', + metadata: { + extension: new ExtensionIdentifier('fixture.extension'), + id: 'gpt-5.6-luna-responsive', + name: 'GPT-5.6 Luna Responsive Preview', + vendor: 'openai', + family: 'gpt', + version: '1', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: { [ChatAgentLocation.Chat]: true }, + configurationSchema: { + properties: { + effort: { + type: 'string', + group: 'navigation', + enum: ['low', 'medium', 'high'], + enumItemLabels: ['Low', 'Medium', 'Max 1M'], + default: 'high', + }, + }, + }, + }, +}; + +const responsiveCollapseWidths = [560, 500, 440, 380, 320, 260, 220, 180]; +const responsiveResizeCycles = [ + ...responsiveCollapseWidths, + ...[...responsiveCollapseWidths].reverse(), + ...responsiveCollapseWidths, + ...[...responsiveCollapseWidths].reverse(), + ...responsiveCollapseWidths, + ...[...responsiveCollapseWidths].reverse(), +]; + export default defineThemedFixtureGroup({ path: 'sessions/chat/input/' }, { SessionsWindow: defineComponentFixture({ render: context => renderChatInput(sessionsWindowContext(context), { @@ -44,4 +83,41 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/input/' }, { selection: { startLineNumber: 1, startColumn: 3, endLineNumber: 3, endColumn: 8 }, }) }), + ResponsiveModelResizeCycleExpanded: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + width: 600, + resizeWidths: [...responsiveResizeCycles, 600], + }) + }), + ResponsiveModelResizeCycleEllipsized: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + width: 600, + resizeWidths: [...responsiveResizeCycles, 380], + }) + }), + ResponsiveModelResizeCycleCompact: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + width: 600, + resizeWidths: [...responsiveResizeCycles, 320], + }) + }), + ResponsiveModelResizeCycleMinimal: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderChatInput(sessionsWindowContext(context), { + isSessionsWindow: true, + models: [responsiveModel], + voiceControl: 'voiceListening', + width: 600, + resizeWidths: [...responsiveResizeCycles, 260], + }) + }), }); 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 3a8fed1ac50230..4d6fec7b7b1453 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -54,6 +54,30 @@ suite('Sessions - Chat View', () => { assert.strictEqual(dom.getWindow(label).getComputedStyle(label).display, 'none'); }); + test('keeps the model configuration label beside the compact model icon', () => { + const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); + disposables.add(toDisposable(() => toolbar.remove())); + const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.chat-input-picker-item.compact-picker.model-picker-item')); + const picker = dom.append(item, dom.$('.action-label.model-picker-split.compact')); + const name = dom.append(picker, dom.$('.model-picker-section.model-picker-name')); + name.style.minWidth = '22px'; + dom.append(name, dom.$('span.codicon')); + const config = dom.append(picker, dom.$('.model-picker-section.model-picker-config')); + const configLabel = dom.append(config, dom.$('span.chat-input-picker-label')); + configLabel.textContent = 'High'; + + assert.deepStrictEqual({ + configVisible: dom.getWindow(configLabel).getComputedStyle(configLabel).display !== 'none', + configWidth: config.getBoundingClientRect().width > 0, + nameWidth: name.getBoundingClientRect().width, + }, { + configVisible: true, + configWidth: true, + nameWidth: 22, + }); + }); + test('keeps compact empty-state picker icons inside their action item', () => { const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); disposables.add(toDisposable(() => toolbar.remove())); @@ -111,6 +135,49 @@ suite('Sessions - Chat View', () => { }); }); + test('keeps the voice toolbar visible when picker actions run out of space', () => { + const session = dom.append(document.body, dom.$('.interactive-session')); + disposables.add(toDisposable(() => session.remove())); + const toolbars = dom.append(session, dom.$('.chat-input-toolbars')); + toolbars.style.width = '180px'; + const inputToolbar = dom.append(toolbars, dom.$('.monaco-toolbar.responsive.chat-input-toolbar')); + inputToolbar.style.width = '240px'; + const executeToolbar = dom.append(toolbars, dom.$('.chat-execute-toolbar')); + executeToolbar.style.width = '70px'; + + assert.deepStrictEqual({ + inputWidth: inputToolbar.getBoundingClientRect().width, + executeWidth: executeToolbar.getBoundingClientRect().width, + executeEscapes: executeToolbar.getBoundingClientRect().right > toolbars.getBoundingClientRect().right, + }, { + inputWidth: 108, + executeWidth: 70, + executeEscapes: false, + }); + }); + + test('focuses the embedded composer frame only for editor focus', () => { + const workbench = dom.append(document.body, dom.$('.monaco-workbench')); + disposables.add(toDisposable(() => workbench.remove())); + workbench.style.setProperty('--vscode-agentsChatInput-border', 'rgb(255, 0, 0)'); + workbench.style.setProperty('--vscode-agentsChatInput-focusBorder', 'rgb(0, 255, 0)'); + const widget = dom.append(workbench, dom.$('.new-chat-in-session')); + const inputArea = dom.append(widget, dom.$('.new-chat-input-area')); + const picker = dom.append(inputArea, dom.$('button')); + + picker.focus(); + const pickerFocusedBorder = dom.getWindow(inputArea).getComputedStyle(inputArea).borderColor; + inputArea.classList.add('focused'); + + assert.deepStrictEqual({ + pickerFocusedBorder, + editorFocusedBorder: dom.getWindow(inputArea).getComputedStyle(inputArea).borderColor, + }, { + pickerFocusedBorder: 'rgb(255, 0, 0)', + editorFocusedBorder: 'rgb(0, 255, 0)', + }); + }); + test('does not forward aquarium visibility to the peer chat composer', () => { const isVisible = observableValue(disposables, true); const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { 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 4b2360ccbdf587..0a8deaa5de612f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -37,6 +37,7 @@ const clearDraftState = Reflect.get(NewChatInputWidget.prototype, '_clearDraftSt const updateDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateDraftState') as (this: IUpdateDraftStateHarness) => void; const updateAndSaveDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateAndSaveDraftState') as (this: IUpdateAndSaveDraftStateHarness) => 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; interface IDraftStateHarness { @@ -125,6 +126,31 @@ class InputModelReferenceHarness implements IInputModelReferenceHarness, IDispos suite('NewChatInputWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('only keeps the input frame focused while editor text has focus', () => { + const stack = document.createElement('div'); + stack.classList.add('chat-input-stack'); + const inputArea = document.createElement('div'); + stack.appendChild(inputArea); + + setInputEditorFocused(inputArea, true); + const focused = { + input: inputArea.classList.contains('focused'), + stack: stack.classList.contains('chat-input-stack-input-focused'), + }; + setInputEditorFocused(inputArea, false); + + assert.deepStrictEqual({ + focused, + blurred: { + input: inputArea.classList.contains('focused'), + stack: stack.classList.contains('chat-input-stack-input-focused'), + }, + }, { + focused: { input: true, stack: true }, + blurred: { input: false, stack: false }, + }); + }); + test('keeps the input model alive until reference acquisition settles during disposal', async () => { const referenceDeferred = new DeferredPromise>(); let modelDisposed = false; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 3b019af428d316..63d6829065226e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -837,7 +837,9 @@ export class AgentHostSessionConfigPicker extends Disposable { return toActionItems(property, filteredItems, provider.getSessionConfig(sessionId)?.values[property] ?? schema.default, filteredPolicyRestricted, filteredRepositoryState.branchName, filteredRepositoryState.uncommittedChanges, onShowChanges); }) : undefined, - onHide: () => trigger.focus(), + onHide: () => { + trigger.focus(); + }, }; this._actionWidgetService.show( diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 2221a0a7450c56..c1a039b0e4a217 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { mock } from '../../../../../../../base/test/common/mock.js'; @@ -277,6 +278,33 @@ suite('Agent Host Session Config Picker', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('restores pointer and keyboard focus without leaving pointer focus visible', async () => { + const services = setupServices(store); + const { container } = renderPicker(store, services); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + const trigger = branchSlot(container)!.querySelector('a.action-label')!; + let focusCalls = 0; + trigger.focus = () => focusCalls++; + + trigger.click(); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate!.onHide(); + const pointerFocusCalls = focusCalls; + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate!.onHide(); + + assert.deepStrictEqual({ + pointerFocusCalls, + keyboardFocusCalls: focusCalls, + }, { + pointerFocusCalls: 1, + keyboardFocusCalls: 2, + }); + }); + test('places mode immediately before approvals in secondary toolbars', () => { const summarize = (menu: MenuId, ids: readonly string[]) => MenuRegistry.getMenuItems(menu) .filter(isIMenuItem) diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts index 2411b95643b525..37f7eac0b241ef 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts @@ -78,14 +78,16 @@ function setupPicker(store: Pick[] = []; let onSelect: ((item: IAgentHostSessionEnumPickerItem) => void) | undefined; + let onHide: (() => void) | undefined; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, - show: (_id: string, _supportsPreview: boolean, items: IActionListItem[], delegate: { onSelect: (item: T) => void }) => { + show: (_id: string, _supportsPreview: boolean, items: IActionListItem[], delegate: { onSelect: (item: T) => void; onHide: () => void }) => { actionWidgetItems.splice(0, actionWidgetItems.length, ...(items as IActionListItem[])); onSelect = delegate.onSelect as (item: IAgentHostSessionEnumPickerItem) => void; + onHide = delegate.onHide; }, }); const sessionObs = observableValue('activeSession', { providerId: PROVIDER_ID, sessionId: SESSION_ID } as IActiveSession); @@ -113,9 +115,10 @@ function setupPicker(store: Pick('a.action-label')?.click(); + const trigger = container.querySelector('a.action-label'); + trigger?.click(); - return { actionWidgetItems, openedResources, onSelect: () => onSelect, provider }; + return { actionWidgetItems, openedResources, onSelect: () => onSelect, onHide: () => onHide, provider, trigger }; } suite('AgentHostClaudePermissionModePicker', () => { @@ -145,6 +148,28 @@ suite('AgentHostClaudePermissionModePicker', () => { assert.strictEqual(new Set(iconIds).size, modeItems.length); }); + test('restores trigger focus after pointer and keyboard activation', () => { + const { onHide, trigger } = setupPicker(store); + let focusCalls = 0; + assert.ok(trigger); + trigger.focus = () => focusCalls++; + assert.ok(onHide()); + onHide()!(); + const pointerFocusCalls = focusCalls; + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.ok(onHide()); + onHide()!(); + + assert.deepStrictEqual({ + pointerFocusCalls, + keyboardFocusCalls: focusCalls, + }, { + pointerFocusCalls: 1, + keyboardFocusCalls: 2, + }); + }); + test('Learn More footer opens docs without writing session config', () => { const { actionWidgetItems, openedResources, onSelect, provider } = setupPicker(store); const learnMoreItem = actionWidgetItems.at(-1)?.item; diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts index 9188c06eea1ed9..96e0056fdc4dd7 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts @@ -47,11 +47,15 @@ export class MobilePermissionPicker extends PermissionPicker { } override showPicker(): void { + this._showPicker(); + } + + protected override _showPicker(): void { if (!this._triggerElement || this.actionWidgetService.isVisible || this._isResolving()) { return; } if (!isPhoneLayout(this._layoutService)) { - super.showPicker(); + super._showPicker(); return; } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts index ccf04379e50a62..79910f44eb6dca 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts @@ -254,7 +254,9 @@ export class ModePicker extends Disposable { this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, AICustomizationManagementSection.Agents); } }, - onHide: () => { triggerElement.focus(); }, + onHide: () => { + triggerElement.focus(); + }, }; this.actionWidgetService.show( diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index 17ef3412c0078f..f401ff7813d7a9 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -195,14 +195,14 @@ export class PermissionPicker extends Disposable { for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => { dom.EventHelper.stop(e, true); - this.showPicker(); + this._showPicker(); })); } this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { dom.EventHelper.stop(e, true); - this.showPicker(); + this._showPicker(); } })); @@ -258,6 +258,10 @@ export class PermissionPicker extends Disposable { } showPicker(): void { + this._showPicker(); + } + + protected _showPicker(): void { if (!this._triggerElement || this.actionWidgetService.isVisible || this._isResolving()) { return; } @@ -342,7 +346,9 @@ export class PermissionPicker extends Disposable { await this.openerService.open(URI.parse('https://aka.ms/vscode/docs/permissions')); } }, - onHide: () => { triggerElement.focus(); }, + onHide: () => { + triggerElement.focus(); + }, }; const listOptions: IActionListOptions = { minWidth: 255 }; diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts index 0ce79b7b3b13dd..4f108ac1e88c74 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { Event } from '../../../../../../base/common/event.js'; import { hash } from '../../../../../../base/common/hash.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; @@ -86,6 +86,7 @@ suite('ModePicker', () => { }()); let selectCustomAgent: (() => void) | undefined; + let hidePicker: (() => void) | undefined; const requestedChatResources: string[] = []; const picker = store.add(new ModePicker( model, @@ -103,6 +104,7 @@ suite('ModePicker', () => { assert.ok(item?.item); const modeItem = item.item; selectCustomAgent = () => delegate.onSelect(modeItem); + hidePicker = () => delegate.onHide(); } override hide(): void { } }(), @@ -124,14 +126,33 @@ suite('ModePicker', () => { }(), )); const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); picker.render(container); - container.querySelector('a.action-label')?.click(); + const trigger = container.querySelector('a.action-label'); + let focusCalls = 0; + if (trigger) { + trigger.focus = () => focusCalls++; + } + trigger?.click(); + assert.ok(hidePicker); + hidePicker(); + const pointerFocusCalls = focusCalls; + + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.ok(hidePicker); + hidePicker(); + const keyboardFocusCalls = focusCalls; + + trigger?.click(); assert.ok(selectCustomAgent); selectCustomAgent(); assert.deepStrictEqual({ events: telemetryService.events.filter(event => event.name === 'chat.modeChange'), requestedChatResources, + pointerFocusCalls, + keyboardFocusCalls, }, { events: [{ name: 'chat.modeChange', @@ -147,6 +168,8 @@ suite('ModePicker', () => { }, }], requestedChatResources: [chatResource.toString()], + pointerFocusCalls: 1, + keyboardFocusCalls: 2, }); }); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts index bf10e7d1008fc3..fe67cb3b06736d 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts @@ -4,12 +4,71 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IActionListDelegate, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; -import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta } from '../../browser/permissionPicker.js'; +import { TestStorageService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; +import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta, IPermissionPickerDelegate, PermissionPicker } from '../../browser/permissionPicker.js'; suite('Copilot PermissionPicker', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('restores trigger focus after pointer and keyboard activation', () => { + let onHide: (() => void) | undefined; + const actionWidgetService = new class extends mock() { + override readonly isVisible = false; + override show(_user: string, _supportsPreview: boolean, _items: readonly IActionListItem[], delegate: IActionListDelegate): void { + onHide = delegate.onHide; + } + override hide(): void { } + }(); + const delegate: IPermissionPickerDelegate = { + getPermissionLevelMeta: (_level, meta) => meta, + setPermissionLevel: () => { }, + }; + const picker = store.add(new PermissionPicker( + delegate, + actionWidgetService, + new TestConfigurationService(), + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestStorageService()), + NullTelemetryService, + new class extends mock() { + override setupDelayedHover() { return { dispose: () => { } }; } + }(), + )); + const container = document.createElement('div'); + picker.render(container); + const trigger = container.querySelector('a.action-label'); + assert.ok(trigger); + let focusCalls = 0; + trigger.focus = () => focusCalls++; + + trigger.click(); + assert.ok(onHide); + onHide(); + const pointerFocusCalls = focusCalls; + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.ok(onHide); + onHide(); + + assert.deepStrictEqual({ + pointerFocusCalls, + keyboardFocusCalls: focusCalls, + }, { + pointerFocusCalls: 1, + keyboardFocusCalls: 2, + }); + }); test('uses descriptions aligned with the agent host permission picker', () => { assert.deepStrictEqual(DEFAULT_PERMISSION_LEVELS.map(level => ({ diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index bc07468326c781..7ad8f398f95ad9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -186,13 +186,18 @@ const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 1 const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2'; const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel'; -function getToolbarPickerResponsiveItems(toolbar: MenuWorkbenchToolBar, compactStates: ReadonlyMap>): IChatInputPickerResponsiveLayoutItem[] { +function getToolbarPickerResponsiveItems( + toolbar: MenuWorkbenchToolBar, + compactStates: ReadonlyMap>, + minimalStates?: ReadonlyMap>, +): IChatInputPickerResponsiveLayoutItem[] { const items: IChatInputPickerResponsiveLayoutItem[] = []; const visibleActionIds = new Set(); for (let index = 0; index < toolbar.getItemsLength(); index++) { const action = toolbar.getItemAction(index); const state = action && compactStates.get(action.id); + const minimalState = action && minimalStates?.get(action.id); const viewItem = toolbar.getItemViewItem(index); const viewItemState = isChatInputPickerResponsiveState(viewItem) ? viewItem : undefined; if (!action || (!state && !viewItemState)) { @@ -202,21 +207,27 @@ function getToolbarPickerResponsiveItems(toolbar: MenuWorkbenchToolBar, compactS const element = toolbar.getItemElement(index); items.push({ element, + canShrink: action.id === OpenModelPickerAction.ID, isCompact: () => viewItemState?.isCompact() ?? state!.get(), + isMinimal: minimalState ? () => minimalState.get() : undefined, setCompact: compact => { state?.set(compact, undefined); viewItemState?.setCompact(compact); element?.classList.toggle('compact-picker', compact); }, + setMinimal: minimalState ? minimal => minimalState.set(minimal, undefined) : undefined, }); } for (const [actionId, state] of compactStates) { if (!visibleActionIds.has(actionId)) { + const minimalState = minimalStates?.get(actionId); items.push({ element: undefined, isCompact: () => state.get(), + isMinimal: minimalState ? () => minimalState.get() : undefined, setCompact: compact => state.set(compact, undefined), + setMinimal: minimalState ? minimal => minimalState.set(minimal, undefined) : undefined, }); } } @@ -3445,6 +3456,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }); const inputPickerCompactStates = new Map>(); + const inputPickerMinimalStates = new Map>(); const secondaryPickerCompactStates = new Map>(); const inputOverflowPickerHandlers = new Map void>(); const secondaryOverflowPickerHandlers = new Map void>(); @@ -3460,6 +3472,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, compact: getCompactState(inputPickerCompactStates, actionId), + minimal: actionId === OpenModelPickerAction.ID ? getCompactState(inputPickerMinimalStates, actionId) : undefined, }); const getSecondaryPickerOptions = (actionId: string): IChatInputPickerOptions => ({ getOverflowAnchor: () => this.secondaryToolbar.getElement(), @@ -3522,6 +3535,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ConfigureToolsAction.ID, ]); const getInputActionMinWidth = (action: IAction): number | undefined => { + if (action.id === OpenModelPickerAction.ID) { + return this.modelWidget?.minimumWidth ?? 60; + } if (shorterChatInputActionIds.has(action.id)) { return 22; } @@ -3538,7 +3554,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge responsiveBehavior: { enabled: true, kind: 'last', - minItems: 1, + minItems: 2, actionMinWidth: 48, getActionMinWidth: getInputActionMinWidth, allowOverflow: () => this._inputPickerResponsiveLayout?.areAllItemsCompact() === true, @@ -3925,7 +3941,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const inputToolbarElement = this.inputActionsToolbar.getElement(); this._inputPickerResponsiveLayout = this._register(new ChatInputPickerResponsiveLayout('ChatInputPart.primaryPicker', inputToolbarElement, { - getItems: () => getToolbarPickerResponsiveItems(this.inputActionsToolbar, inputPickerCompactStates), + getItems: () => getToolbarPickerResponsiveItems(this.inputActionsToolbar, inputPickerCompactStates, inputPickerMinimalStates), hasOverflow: () => this.inputActionsToolbar.hasOverflow(), relayout: () => this.inputActionsToolbar.relayout(), })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index d1c3aea1d5720a..cbbf97b58e92c3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -27,6 +27,8 @@ export interface IChatInputPickerOptions { readonly compact: IObservable; + readonly minimal?: IObservable; + readonly listOptions?: IActionListOptions; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts index 67f8044dd2e2a1..eba6955568cd6a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.ts @@ -17,10 +17,14 @@ export interface IChatInputPickerResponsiveLayoutDelegate { export interface IChatInputPickerResponsiveState { isCompact(): boolean; setCompact(compact: boolean): void; + isMinimal?(): boolean; + setMinimal?(minimal: boolean): void; } export interface IChatInputPickerResponsiveLayoutItem extends IChatInputPickerResponsiveState { readonly element: HTMLElement | undefined; + /** Let the item's CSS minimum width determine when its expanded form compacts. */ + readonly canShrink?: boolean; } export function isChatInputPickerResponsiveState(candidate: object | undefined): candidate is IChatInputPickerResponsiveState { @@ -68,29 +72,28 @@ export class ChatInputPickerResponsiveLayout extends Disposable { this._isLayouting = true; this._mutationObserver.disconnect(); try { - // Restore as many hidden actions as possible in their shortest form - // before measuring. Otherwise an overflow menu can hide the very items - // whose expanded width should keep the lane compact. - this._setAllCompact(true); - this._delegate.relayout?.(); - this._setAllCompact(true); - this._delegate.relayout?.(); - if (this._delegate.hasOverflow?.()) { + if (!this._restoreCompactItems()) { return; } const items = this._getOrderedVisibleItems(); for (const item of items) { + item.setMinimal?.(false); item.setCompact(false); } + this._delegate.relayout?.(); for (const item of items) { if (this._fitsAvailableWidth(availableWidth)) { break; } item.setCompact(true); + this._delegate.relayout?.(); + if (!this._fitsAvailableWidth(availableWidth)) { + item.setMinimal?.(true); + this._delegate.relayout?.(); + } } - this._delegate.relayout?.(); } finally { this._observeMutations(); this._isLayouting = false; @@ -98,12 +101,36 @@ export class ChatInputPickerResponsiveLayout extends Disposable { } areAllItemsCompact(): boolean { - return this._delegate.getItems().every(item => item.isCompact()); + return this._delegate.getItems().every(item => item.isCompact() && (!item.isMinimal || item.isMinimal())); } - private _setAllCompact(compact: boolean): void { + private _restoreCompactItems(): boolean { + if (!this._delegate.hasOverflow?.()) { + return true; + } + + let visibleItemCount = this._getOrderedVisibleItems().length; + while (true) { + this._setHiddenItemsCompact(); + this._delegate.relayout?.(); + if (!this._delegate.hasOverflow?.()) { + return true; + } + + const nextVisibleItemCount = this._getOrderedVisibleItems().length; + if (nextVisibleItemCount <= visibleItemCount) { + return false; + } + visibleItemCount = nextVisibleItemCount; + } + } + + private _setHiddenItemsCompact(): void { for (const item of this._delegate.getItems()) { - item.setCompact(compact); + if (!item.element?.isConnected) { + item.setCompact(true); + item.setMinimal?.(true); + } } } @@ -116,7 +143,7 @@ export class ChatInputPickerResponsiveLayout extends Disposable { private _fitsAvailableWidth(availableWidth: number): boolean { const items = this._getOrderedVisibleItems(); const preferredLayout = this._measurePreferredLayout(items); - if (preferredLayout.width > availableWidth + WIDTH_TOLERANCE) { + if (!items.some(item => item.canShrink) && preferredLayout.width > availableWidth + WIDTH_TOLERANCE) { return false; } @@ -133,7 +160,7 @@ export class ChatInputPickerResponsiveLayout extends Disposable { return false; } const preferredWidth = preferredLayout.itemWidths.get(item); - if (preferredWidth !== undefined && bounds.width < preferredWidth - WIDTH_TOLERANCE) { + if (!item.canShrink && preferredWidth !== undefined && bounds.width < preferredWidth - WIDTH_TOLERANCE) { return false; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index 601a7065c5f52c..be4d1807694a0c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -3,10 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.chat-input-picker-item:has(.model-picker-split) { +.chat-input-picker-item.model-picker-item { overflow: visible; } +.interactive-session .chat-input-toolbar .chat-input-picker-item.model-picker-item .action-label.model-picker-split { + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +.interactive-session .chat-input-toolbar.monaco-toolbar.responsive .monaco-action-bar > .actions-container > .action-item.model-picker-item { + flex-shrink: 1; +} + .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.model-picker-split { padding: 0; overflow: visible; @@ -51,8 +61,9 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name { - flex-shrink: 0; - overflow: visible; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; } .interactive-session .chat-input-toolbar .chat-input-picker-item.compact-picker .action-label.model-picker-split.compact { @@ -60,8 +71,29 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { - overflow: visible; - text-overflow: clip; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.interactive-session .chat-input-toolbars > .chat-input-toolbar .chat-input-picker-item.model-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-input-picker-item .action-label.model-picker-split.compact .model-picker-name { + flex: 0 0 auto; + padding: 0 var(--vscode-spacing-size60); + justify-content: flex-start; +} + +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.model-picker-split.icon-only.minimal .model-picker-name { + width: 24px; + padding: 0 var(--vscode-spacing-size60); + justify-content: flex-start; } .chat-input-picker-item .action-label.model-picker-split .model-picker-config { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index 75a3c1bad1aaf9..6f39c59144fab7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -17,6 +17,7 @@ import { localize } from '../../../../../../../nls.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../../common/languageModelSourcePresentation.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { IChatInputPickerOptions } from '../chatInputPickerActionItem.js'; import { ModelPickerWidget } from './modelPickerWidget.js'; @@ -84,6 +85,8 @@ export interface IModelPickerDelegate { export class ModelPickerActionItem extends BaseActionViewItem { private readonly _pickerWidget: ModelPickerWidget; private readonly _managedHover = this._register(new MutableDisposable()); + private _container: HTMLElement | undefined; + private _minimumWidth: number | undefined; constructor( action: IAction, @@ -98,6 +101,9 @@ export class ModelPickerActionItem extends BaseActionViewItem { this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget, delegate)); this._pickerWidget.setSelectedModel(delegate.currentModel.get()); this._pickerWidget.setCompact(pickerOptions.compact); + if (pickerOptions.minimal) { + this._pickerWidget.setMinimal(pickerOptions.minimal); + } // Sync delegate → widget when model list or selection changes externally this._register(autorun(t => { @@ -108,13 +114,28 @@ export class ModelPickerActionItem extends BaseActionViewItem { // Sync widget → delegate when user picks a model this._register(this._pickerWidget.onDidChangeSelection(model => delegate.setModel(model))); + this._register(this._pickerWidget.onDidChangeMinimumWidth(width => this._updateMinimumWidth(width))); } override render(container: HTMLElement): void { + this._container = container; this._pickerWidget.render(container); this.element = this._pickerWidget.domNode; this._updateTooltip(); - container.classList.add('chat-input-picker-item'); + container.classList.add('chat-input-picker-item', 'model-picker-item'); + this._updateMinimumWidth(this._pickerWidget.minimumWidth); + } + + get minimumWidth(): number { + return this._pickerWidget.minimumWidth; + } + + private _updateMinimumWidth(width: number): void { + if (!this._container || this._minimumWidth === width) { + return; + } + this._minimumWidth = width; + this._container.style.minWidth = `${width}px`; } private _getAnchorElement(): HTMLElement { @@ -191,7 +212,13 @@ export class ModelPickerActionItem extends BaseActionViewItem { if (this._pickerWidget.isSetupRequired()) { return localize('chat.modelPicker.setupRequiredHover', "{0} • Sign in to GitHub Copilot to choose a model.", label); } - const { statusIcon, tooltip } = this._pickerWidget.selectedModel?.metadata || {}; - return statusIcon && tooltip ? `${label} • ${tooltip}` : label; + const selectedModel = this._pickerWidget.selectedModel; + const { statusIcon, tooltip } = selectedModel?.metadata || {}; + if (selectedModel) { + label = localize('chat.modelPicker.selectedModelHover', "{0} • {1}", label, getLanguageModelDisplayNameWithSubscriptionSource(selectedModel)); + } + return statusIcon && tooltip + ? localize('chat.modelPicker.selectedModelStatusHover', "{0} • {1}", label, tooltip) + : label; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index 292dae4404e156..a9c91949385653 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -48,6 +48,10 @@ import { getCompactModelPickerIcon } from './modelProviderIcons.js'; import { getModelPickerUnavailableReason, isAutoModel, ModelPickerUnavailableReason, modelPickerRequiresSetup, shouldShowCacheBreakHint as computeShouldShowCacheBreakHint } from './modelPickerPresentation.js'; const CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY = 'chat.cacheBreakHintDismissed'; +const MODEL_PICKER_MINIMUM_LABEL_WIDTH = 60; +const MODEL_PICKER_NAME_CHROME_WIDTH = 30; +const MODEL_PICKER_MINIMUM_NAME_WIDTH = MODEL_PICKER_MINIMUM_LABEL_WIDTH + MODEL_PICKER_NAME_CHROME_WIDTH; +const MODEL_PICKER_COMPACT_NAME_WIDTH = 24; type ChatModelChangeClassification = { owner: 'lramos15'; comment: 'Reporting when the model picker is switched'; @@ -102,10 +106,13 @@ export class ModelPickerWidget extends Disposable { private readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; + private readonly _onDidChangeMinimumWidth = this._register(new Emitter()); + readonly onDidChangeMinimumWidth: Event = this._onDidChangeMinimumWidth.event; private _selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; private _badge: ModelPickerBadge | undefined; private _compact: IObservable | undefined; + private _minimal: IObservable | undefined; private _workspaceTrustInitialized = false; private _activatingAfterTrust = false; private readonly _activatingTimer = this._register(new MutableDisposable()); @@ -114,6 +121,7 @@ export class ModelPickerWidget extends Disposable { private _badgeIcon: HTMLElement | undefined; private _nameButton: HTMLElement | undefined; private _configButton: HTMLElement | undefined; + private _minimumWidth = MODEL_PICKER_MINIMUM_NAME_WIDTH; private readonly _configuration: ModelPickerConfiguration; get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { @@ -128,6 +136,18 @@ export class ModelPickerWidget extends Disposable { return this._nameButton; } + get minimumWidth(): number { + return this._minimumWidth; + } + + private _updateMinimumWidth(nameWidth: number): void { + const minimumWidth = nameWidth + (this._configButton?.offsetWidth ?? 0); + if (this._minimumWidth !== minimumWidth) { + this._minimumWidth = minimumWidth; + this._onDidChangeMinimumWidth.fire(minimumWidth); + } + } + constructor( private readonly _delegate: IModelPickerDelegate, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @@ -220,6 +240,15 @@ export class ModelPickerWidget extends Disposable { })); } + setMinimal(minimal: IObservable): void { + this._minimal = minimal; + this._register(autorun(reader => { + const isMinimal = minimal.read(reader); + this._domNode?.classList.toggle('minimal', isMinimal); + this._renderLabel(); + })); + } + setSelectedModel(model: ILanguageModelChatMetadataAndIdentifier | undefined): void { this._selectedModel = model; this._renderLabel(); @@ -312,6 +341,9 @@ export class ModelPickerWidget extends Disposable { if (this._compact?.get()) { this._domNode.classList.toggle('compact', true); } + if (this._minimal?.get()) { + this._domNode.classList.toggle('minimal', true); + } // Model name button this._nameButton = dom.append(this._domNode, dom.$('a.model-picker-section.model-picker-name')); @@ -595,6 +627,7 @@ export class ModelPickerWidget extends Disposable { : this._selectedModel.metadata.statusIcon ? getCompactCodicon(this._selectedModel.metadata.statusIcon) : undefined) : undefined; const compact = this._compact?.get() ?? false; + const minimal = this._minimal?.get() ?? false; if (modelIcon && !noModelsAvailable) { nameChildren.push(renderIcon(modelIcon)); } @@ -610,6 +643,8 @@ export class ModelPickerWidget extends Disposable { ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); const showModelLabel = !compact || !modelIcon || noModelsAvailable; + const nameMinimumWidth = compact && !showModelLabel ? MODEL_PICKER_COMPACT_NAME_WIDTH : MODEL_PICKER_MINIMUM_NAME_WIDTH; + this._nameButton.style.minWidth = `${nameMinimumWidth}px`; if (showModelLabel) { nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); } @@ -618,11 +653,11 @@ export class ModelPickerWidget extends Disposable { } dom.reset(this._nameButton, ...nameChildren); - this._domNode.classList.toggle('icon-only', !showModelLabel); - if (this._configButton) { - this._configuration.renderButton(this._configButton, compact, noModelsAvailable); + this._configuration.renderButton(this._configButton, minimal, noModelsAvailable); } + const configVisible = !!this._configButton && this._configButton.style.display !== 'none'; + this._domNode.classList.toggle('icon-only', !showModelLabel && !configVisible); // Aria — name the control "Models" to match the visible label; the comma // separates the control name from its current value / state. @@ -633,6 +668,7 @@ export class ModelPickerWidget extends Disposable { : localize('chat.modelPicker.ariaLabel', "Models, {0}", modelLabel); this._domNode.ariaLabel = ariaLabel; this._nameButton.ariaLabel = ariaLabel; + this._updateMinimumWidth(nameMinimumWidth); } } 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 cb18b7ecf09885..40c0bfef5c1efd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1348,6 +1348,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars > .chat-execute-toolbar { order: 2; + flex-shrink: 0; } .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .actions-container { @@ -1951,7 +1952,7 @@ have to be updated for changes to the rules above, or to support more deeply nes order: 0; overflow: hidden; min-width: 0px; - width: 100%; + flex: 1 1 0; color: var(--vscode-icon-foreground); .monaco-action-bar .action-item .codicon { 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 69609fa4312a40..166f46374b43ee 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 @@ -610,8 +610,18 @@ suite('ChatPetWidget', () => { 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 new Promise(resolve => mainWindow.setTimeout(resolve, 10)); + await windowTransferred; const hiddenAfterWindowTransfer = button.classList.contains('hidden'); mainWindow.dispatchEvent(new FocusEvent('focus')); const hiddenAfterReturn = button.classList.contains('hidden'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts index 1950c44e45a277..2a191963c2c771 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts @@ -154,12 +154,12 @@ suite('ChatInputPickerResponsiveLayout', () => { lane.style.overflow = 'hidden'; const actionBar = dom.append(lane, dom.$('.monaco-action-bar.has-overflow')); - const picker = dom.append(actionBar, dom.$('.picker')); + const picker = dom.$('.picker'); let compact = false; let overflow = true; const layout = store.add(new ChatInputPickerResponsiveLayout('test.overflowedPickerLane', lane, { getItems: () => [{ - element: picker, + element: picker.isConnected ? picker : undefined, isCompact: () => compact, setCompact: value => { compact = value; @@ -168,7 +168,10 @@ suite('ChatInputPickerResponsiveLayout', () => { }], hasOverflow: () => overflow, relayout: () => { - overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + if (!picker.isConnected && compact && Number.parseFloat(picker.style.width) <= lane.getBoundingClientRect().width) { + actionBar.appendChild(picker); + overflow = false; + } }, })); @@ -190,6 +193,59 @@ suite('ChatInputPickerResponsiveLayout', () => { }); }); + test('restores every hidden picker across repeated overflow cycles', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '400px'; + + const compact = [true, true, true, true]; + const pickers = compact.map((_, index) => { + const picker = dom.$(`.picker-${index}`); + picker.style.width = '20px'; + return picker; + }); + let visibleItemCount = 1; + let overflow = true; + lane.appendChild(pickers[0]); + + const hidePickers = () => { + for (const picker of pickers.slice(1)) { + picker.remove(); + } + visibleItemCount = 1; + overflow = true; + }; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.restoreAllPickerLane', lane, { + getItems: () => pickers.map((picker, index) => ({ + element: picker, + isCompact: () => compact[index], + setCompact: value => { + compact[index] = value; + picker.style.width = value ? '20px' : '80px'; + }, + })), + hasOverflow: () => overflow, + relayout: () => { + if (visibleItemCount < pickers.length && compact.slice(visibleItemCount).every(Boolean)) { + lane.appendChild(pickers[visibleItemCount++]); + } + overflow = visibleItemCount < pickers.length; + }, + })); + + layout.layout(); + const firstRestore = { visibleItemCount, compact: [...compact], overflow }; + + hidePickers(); + layout.layout(); + const secondRestore = { visibleItemCount, compact: [...compact], overflow }; + + assert.deepStrictEqual({ firstRestore, secondRestore }, { + firstRestore: { visibleItemCount: 4, compact: [false, false, false, false], overflow: false }, + secondRestore: { visibleItemCount: 4, compact: [false, false, false, false], overflow: false }, + }); + }); + test('compacts a picker whose rendered bounds escape the lane', () => { const lane = dom.append(host, dom.$('.picker-lane')); lane.style.display = 'flex'; @@ -255,6 +311,96 @@ suite('ChatInputPickerResponsiveLayout', () => { assert.strictEqual(compact, true); }); + test('lets a shrinkable picker ellipsize before compacting at its minimum width', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '100px'; + + const picker = dom.append(lane, dom.$('.picker')); + picker.style.flex = '0 1 160px'; + picker.style.width = '160px'; + picker.style.minWidth = '60px'; + picker.style.overflow = 'hidden'; + const label = dom.append(picker, dom.$('.picker-label')); + label.style.overflow = 'hidden'; + label.style.textOverflow = 'ellipsis'; + label.style.whiteSpace = 'nowrap'; + label.textContent = 'A picker label that can ellipsize'; + + let compact = false; + let overflow = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.shrinkablePickerLane', lane, { + getItems: () => [{ + element: picker, + canShrink: true, + isCompact: () => compact, + setCompact: value => { + compact = value; + }, + }], + hasOverflow: () => overflow, + relayout: () => { + picker.style.flexBasis = compact ? '20px' : '160px'; + picker.style.width = compact ? '20px' : '160px'; + picker.style.minWidth = compact ? '20px' : '60px'; + overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + }, + })); + + layout.layout(); + const truncated = { compact, overflow, width: picker.getBoundingClientRect().width }; + + lane.style.width = '50px'; + layout.layout(); + const collapsed = { compact, overflow, width: picker.getBoundingClientRect().width }; + + lane.style.width = '15px'; + layout.layout(); + const overflowed = { compact, overflow }; + + assert.deepStrictEqual({ truncated, collapsed, overflowed }, { + truncated: { compact: false, overflow: false, width: 100 }, + collapsed: { compact: true, overflow: false, width: 20 }, + overflowed: { compact: true, overflow: true }, + }); + }); + + test('uses a minimal picker state before overflowing', () => { + const lane = dom.append(host, dom.$('.picker-lane')); + lane.style.display = 'flex'; + lane.style.width = '50px'; + + const picker = dom.append(lane, dom.$('.picker')); + let compact = false; + let minimal = false; + let overflow = false; + const layout = store.add(new ChatInputPickerResponsiveLayout('test.minimalPickerLane', lane, { + getItems: () => [{ + element: picker, + canShrink: true, + isCompact: () => compact, + isMinimal: () => minimal, + setCompact: value => compact = value, + setMinimal: value => minimal = value, + }], + hasOverflow: () => overflow, + relayout: () => { + picker.style.width = minimal ? '20px' : compact ? '60px' : '120px'; + picker.style.minWidth = minimal ? '20px' : '60px'; + overflow = picker.getBoundingClientRect().width > lane.getBoundingClientRect().width; + }, + })); + + layout.layout(); + + assert.deepStrictEqual({ compact, minimal, overflow, width: picker.getBoundingClientRect().width }, { + compact: true, + minimal: true, + overflow: false, + width: 20, + }); + }); + test('keeps the toolbar row height stable when the model picker overflows', () => { host.style.setProperty('--vscode-spacing-size40', '4px'); host.style.setProperty('--vscode-spacing-size60', '6px'); @@ -264,10 +410,15 @@ suite('ChatInputPickerResponsiveLayout', () => { row.style.display = 'flex'; row.style.alignItems = 'center'; - const modelItem = dom.append(row, dom.$('.chat-input-picker-item')); + const modelItem = dom.append(row, dom.$('.chat-input-picker-item.model-picker-item')); + modelItem.style.width = '100px'; const modelLabel = dom.append(modelItem, dom.$('a.action-label.model-picker-split')); const modelName = dom.append(modelLabel, dom.$('.model-picker-section.model-picker-name')); + modelName.style.minWidth = '90px'; const pickerLabel = dom.append(modelName, dom.$('.chat-input-picker-label')); + pickerLabel.textContent = 'A very long model name'; + const modelConfig = dom.append(modelLabel, dom.$('.model-picker-section.model-picker-config')); + modelConfig.style.width = '40px'; const overflowItem = dom.append(row, dom.$('.overflow-item')); overflowItem.style.width = '22px'; @@ -275,6 +426,11 @@ suite('ChatInputPickerResponsiveLayout', () => { overflowItem.style.display = 'none'; const withModelPicker = row.getBoundingClientRect().height; + const expandedModelNameFlexShrink = dom.getWindow(modelName).getComputedStyle(modelName).flexShrink; + const expandedLabelTextOverflow = dom.getWindow(pickerLabel).getComputedStyle(pickerLabel).textOverflow; + const expandedModelPickerWidth = modelLabel.getBoundingClientRect().width; + const expandedModelNameWidth = modelName.getBoundingClientRect().width; + const expandedLabelTruncated = pickerLabel.scrollWidth > pickerLabel.clientWidth; const expandedIconOffset = modelName.getBoundingClientRect().left - modelLabel.getBoundingClientRect().left; modelLabel.style.width = '22px'; modelItem.classList.add('compact-picker'); @@ -287,15 +443,21 @@ suite('ChatInputPickerResponsiveLayout', () => { assert.deepStrictEqual({ withModelPicker, withOverflow, - modelNameFlexShrink: dom.getWindow(modelName).getComputedStyle(modelName).flexShrink, - labelTextOverflow: dom.getWindow(pickerLabel).getComputedStyle(pickerLabel).textOverflow, + expandedModelNameFlexShrink, + expandedLabelTextOverflow, + expandedModelPickerWidth, + expandedModelNameWidth, + expandedLabelTruncated, expandedIconOffset, compactIconOffset, }, { withModelPicker: 22, withOverflow: 22, - modelNameFlexShrink: '0', - labelTextOverflow: 'clip', + expandedModelNameFlexShrink: '1', + expandedLabelTextOverflow: 'ellipsis', + expandedModelPickerWidth: 100, + expandedModelNameWidth: 90, + expandedLabelTruncated: true, expandedIconOffset: 0, compactIconOffset: 0, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts index 1503bb3ad399ba..40981ce1c85e9b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts @@ -86,6 +86,8 @@ export interface ChatInputFixtureOptions { readonly selection?: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; /** Sets the fixture width, useful for exercising the compact picker layout. */ readonly width?: number; + /** Applies additional widths after initial layout to exercise responsive restoration. */ + readonly resizeWidths?: readonly number[]; /** Supplies models so the picker renders provider icons. */ readonly models?: readonly ILanguageModelChatMetadataAndIdentifier[]; /** Renders a standalone dictation / Voice Mode control in the given state. */ @@ -102,7 +104,7 @@ export interface ChatInputFixtureOptions { export async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: ChatInputFixtureOptions = {}): Promise { const { container, disposableStore } = context; - const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, models = [], voiceControl, notification, pet = false } = fixtureOptions; + const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, resizeWidths = [], models = [], voiceControl, notification, pet = false } = fixtureOptions; const artifactGroups: IArtifactSourceGroup[] = artifacts.length > 0 ? [{ source: { kind: 'agent' as const }, artifacts }] : []; const artifactsObs = observableValue('artifactGroups', artifactGroups); @@ -233,6 +235,13 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO inputPart.layout(width); await new Promise(r => setTimeout(r, 100)); inputPart.layout(width); + if (resizeWidths.length > 0) { + await Promise.all(resizeWidths.map((resizeWidth, index) => new Promise(resolve => setTimeout(() => { + container.style.width = `${resizeWidth}px`; + inputPart.layout(resizeWidth); + resolve(); + }, index * 16)))); + } if (value !== undefined) { inputPart.setValue(value, true); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index d8ebdb63585bd2..8d502bb1c4ed46 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -163,10 +163,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f70224f7733a2461eba63fa98234aab38b8804a73460deffa11f49cd6f7172c) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7ae2b4a47fd55deedfef3304fd218f5768fa4847a6ce8749a005755e6bec2d3f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1a8351614d9e211d7f63db3f1f56a46934271cfdbda83b0b72298968d0f5eec5) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/48d5afd53482fa7155777eaf6a6a2dc1f809f5b945d08a9befe9eee63eaf8b0b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a6412a92ec5265fe65d714be9a9af4e0feb95fed7378ca1477629fb2391485a0) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0752cf02ae3a4e21fce84b62859df32a5f41c13622bdec0083a3fd46832c2e0a) From 768c8ceba7b2437e4ccc5c57d6f783bc70c48de2 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:17:48 -0700 Subject: [PATCH 19/21] Bump xterm to 6.1.0-beta.303 (#333612) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f172890-b580-41e9-a79d-c7af8ee372c2 --- package-lock.json | 96 ++++++++++++++++++------------------ package.json | 20 ++++---- remote/package-lock.json | 96 ++++++++++++++++++------------------ remote/package.json | 20 ++++---- remote/web/package-lock.json | 88 ++++++++++++++++----------------- remote/web/package.json | 18 +++---- 6 files changed, 169 insertions(+), 169 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6ae358481845e..470dab9e6c812d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,16 +44,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "foundry-local-sdk": "1.2.3", @@ -5386,27 +5386,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", - "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", + "version": "0.3.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.302.tgz", + "integrity": "sha512-KRH/7iyVE97zNjIhZI674uY62cuekMofSLOxG1TkOdSCofxvgOgbPL6C7R3BrWSG7MX28tM7TzqfdSKKqG2U7g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", - "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.300.tgz", + "integrity": "sha512-eF19wskh7io1KIFMHkJW0yMSTrDdvALp0ZjI4A27czpdTLquyB8LN0w+wNnpOU7D+B4CZp/8LxNta/LGMRIdCg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", - "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", + "version": "0.11.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.300.tgz", + "integrity": "sha512-f+Uol/BSxHSLnqGDjixstqYHwI/cPgUOfCqP5Lc591xe/MlAgaFIBd8xMEAlUnJnz6/jIVoQMSNNhpk8a4asrQ==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -5416,7 +5416,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -5429,63 +5429,63 @@ } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", - "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", + "version": "0.3.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.300.tgz", + "integrity": "sha512-zYxoFTP+5rkdzKYG0imSDJPUnJ6FY+UsqOROMgw8v86Ej9ky6O9K35nH2HmfAp+JfXpC/5oCZ9vFu0KFNe73qw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", - "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", + "version": "0.17.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.300.tgz", + "integrity": "sha512-D3kPBm0qB+AWP+qzgq7JUSCMZOjYxAX9g+bbmLiCBKhgt6dWk40GVG2YgFQRuS55Guj0ooVu93Y/nKEZ7jKxQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", - "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", + "version": "0.15.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.300.tgz", + "integrity": "sha512-OCcnMMOCP+p5pIuXIro+u+wfNkSD8J2CZUqmyfTULxgs7jc+vyDL6UiaKmbs2q6MUcq4F9P5Oju3J8d4SEKOmQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", - "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.300.tgz", + "integrity": "sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.298", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", - "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", + "version": "0.20.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.299.tgz", + "integrity": "sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.301.tgz", - "integrity": "sha512-ApQUwq3BlHA8xlOeBKnC+1l+g95JHVl9bg7EujtUBZA9aKtweyOF9PEd/1larB0c4UF5XAf9lWjj+ICNZNR/1w==", + "version": "6.1.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.302.tgz", + "integrity": "sha512-UZTvl9zme7wu4q8H5dvudJ7jDYk2BkXbDpiuhbDj0eDg0smoDHk5vcHDfy8zokwDCIHCZXV5Yzy45Wi7DC7dXQ==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.302", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", - "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", + "version": "6.1.0-beta.303", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.303.tgz", + "integrity": "sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index 90c815ecb8f8bd..0e1cb64f4ba8eb 100644 --- a/package.json +++ b/package.json @@ -133,16 +133,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "foundry-local-sdk": "1.2.3", diff --git a/remote/package-lock.json b/remote/package-lock.json index 4e39070e1ada5c..c24cdb237b936a 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -28,16 +28,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -1079,27 +1079,27 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", - "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", + "version": "0.3.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.302.tgz", + "integrity": "sha512-KRH/7iyVE97zNjIhZI674uY62cuekMofSLOxG1TkOdSCofxvgOgbPL6C7R3BrWSG7MX28tM7TzqfdSKKqG2U7g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", - "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.300.tgz", + "integrity": "sha512-eF19wskh7io1KIFMHkJW0yMSTrDdvALp0ZjI4A27czpdTLquyB8LN0w+wNnpOU7D+B4CZp/8LxNta/LGMRIdCg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", - "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", + "version": "0.11.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.300.tgz", + "integrity": "sha512-f+Uol/BSxHSLnqGDjixstqYHwI/cPgUOfCqP5Lc591xe/MlAgaFIBd8xMEAlUnJnz6/jIVoQMSNNhpk8a4asrQ==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -1109,67 +1109,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", - "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", + "version": "0.3.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.300.tgz", + "integrity": "sha512-zYxoFTP+5rkdzKYG0imSDJPUnJ6FY+UsqOROMgw8v86Ej9ky6O9K35nH2HmfAp+JfXpC/5oCZ9vFu0KFNe73qw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", - "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", + "version": "0.17.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.300.tgz", + "integrity": "sha512-D3kPBm0qB+AWP+qzgq7JUSCMZOjYxAX9g+bbmLiCBKhgt6dWk40GVG2YgFQRuS55Guj0ooVu93Y/nKEZ7jKxQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", - "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", + "version": "0.15.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.300.tgz", + "integrity": "sha512-OCcnMMOCP+p5pIuXIro+u+wfNkSD8J2CZUqmyfTULxgs7jc+vyDL6UiaKmbs2q6MUcq4F9P5Oju3J8d4SEKOmQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", - "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.300.tgz", + "integrity": "sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.298", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", - "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", + "version": "0.20.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.299.tgz", + "integrity": "sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.301.tgz", - "integrity": "sha512-ApQUwq3BlHA8xlOeBKnC+1l+g95JHVl9bg7EujtUBZA9aKtweyOF9PEd/1larB0c4UF5XAf9lWjj+ICNZNR/1w==", + "version": "6.1.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.302.tgz", + "integrity": "sha512-UZTvl9zme7wu4q8H5dvudJ7jDYk2BkXbDpiuhbDj0eDg0smoDHk5vcHDfy8zokwDCIHCZXV5Yzy45Wi7DC7dXQ==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.302", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", - "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", + "version": "6.1.0-beta.303", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.303.tgz", + "integrity": "sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index e70cd766e4eae0..f8ce30a06757ad 100644 --- a/remote/package.json +++ b/remote/package.json @@ -23,16 +23,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.8.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/headless": "^6.1.0-beta.301", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/headless": "^6.1.0-beta.302", + "@xterm/xterm": "^6.1.0-beta.303", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 0b54259cbb8cb3..92bd7bacbaa711 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/xterm": "^6.1.0-beta.303", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.4.3", @@ -100,27 +100,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.301", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.301.tgz", - "integrity": "sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==", + "version": "0.3.0-beta.302", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.302.tgz", + "integrity": "sha512-KRH/7iyVE97zNjIhZI674uY62cuekMofSLOxG1TkOdSCofxvgOgbPL6C7R3BrWSG7MX28tM7TzqfdSKKqG2U7g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.299.tgz", - "integrity": "sha512-odxXWWAKh2KRIUgXTvQejzkzlvIbpV3aepkkS6uaQKUVYf9HUqWEhECUsUvm2pMgZtqLqGuhmynw/qvKWmHrBQ==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.300.tgz", + "integrity": "sha512-eF19wskh7io1KIFMHkJW0yMSTrDdvALp0ZjI4A27czpdTLquyB8LN0w+wNnpOU7D+B4CZp/8LxNta/LGMRIdCg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.299.tgz", - "integrity": "sha512-+LQqYRdsrBeVLsqizO6whEpHlSUKjJWp53mf4m+9ynDTAXu2YcGB33h4etby8lDLUsh/vXeLZPW4U2rZWK1ILg==", + "version": "0.11.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.300.tgz", + "integrity": "sha512-f+Uol/BSxHSLnqGDjixstqYHwI/cPgUOfCqP5Lc591xe/MlAgaFIBd8xMEAlUnJnz6/jIVoQMSNNhpk8a4asrQ==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -130,58 +130,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.299.tgz", - "integrity": "sha512-hzAGk9UPIbDJ6O5RngTfdGTRRmdsUdBLGWoUoR3ZsEu+UK7utLKHrWV4bdYy5y2PPrkNwujoEThkVBGFh7P9hQ==", + "version": "0.3.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.300.tgz", + "integrity": "sha512-zYxoFTP+5rkdzKYG0imSDJPUnJ6FY+UsqOROMgw8v86Ej9ky6O9K35nH2HmfAp+JfXpC/5oCZ9vFu0KFNe73qw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.299.tgz", - "integrity": "sha512-We4bbjOuLY9oZD5WN93P6STqwlJg3Q7ECSE4UIuzEmWG009yEu4di5HPW/8u7UERUdrg0+8Ds+wGWrDR0687jw==", + "version": "0.17.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.300.tgz", + "integrity": "sha512-D3kPBm0qB+AWP+qzgq7JUSCMZOjYxAX9g+bbmLiCBKhgt6dWk40GVG2YgFQRuS55Guj0ooVu93Y/nKEZ7jKxQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.299.tgz", - "integrity": "sha512-LYGeDgXy2CxUWhx49uL3aoDjM8s9PZhHdw553RgFszekbOAJ8AZoMmYBEnwDl/AOOYnplzcKdWdU37q9W6qTbQ==", + "version": "0.15.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.300.tgz", + "integrity": "sha512-OCcnMMOCP+p5pIuXIro+u+wfNkSD8J2CZUqmyfTULxgs7jc+vyDL6UiaKmbs2q6MUcq4F9P5Oju3J8d4SEKOmQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.299", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.299.tgz", - "integrity": "sha512-BShRMWsKqoHs9fm0L96zjC4Du5L19bvmdZffm5LPKLbVWUAZj7cjjbpXuFU9FsITIJlvjTqeNfq1vulv60zRDA==", + "version": "0.10.0-beta.300", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.300.tgz", + "integrity": "sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.298", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.298.tgz", - "integrity": "sha512-65jZWGSV3nu2jVyc/r2H31Q+oXnDX8IhcSquREVlDjqmfOINXhAufBj2zcK6BCgYT0jrdx7y64VFgVcQ3vAkRA==", + "version": "0.20.0-beta.299", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.299.tgz", + "integrity": "sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.301" + "@xterm/xterm": "^6.1.0-beta.303" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.302", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.302.tgz", - "integrity": "sha512-yTlcgFDNe0ZE7U1RA1JX9oZkVOE8gKvLLh55tMHrS3/ZHkJCOWHlC0mFt3GYyWChSQ1XF+VY0iIme7ZktSaMRA==", + "version": "6.1.0-beta.303", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.303.tgz", + "integrity": "sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 11f0365cab1b76..ca0b95f2ef6265 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.301", - "@xterm/addon-image": "^0.10.0-beta.299", - "@xterm/addon-ligatures": "^0.11.0-beta.299", - "@xterm/addon-progress": "^0.3.0-beta.299", - "@xterm/addon-search": "^0.17.0-beta.299", - "@xterm/addon-serialize": "^0.15.0-beta.299", - "@xterm/addon-unicode11": "^0.10.0-beta.299", - "@xterm/addon-webgl": "^0.20.0-beta.298", - "@xterm/xterm": "^6.1.0-beta.302", + "@xterm/addon-clipboard": "^0.3.0-beta.302", + "@xterm/addon-image": "^0.10.0-beta.300", + "@xterm/addon-ligatures": "^0.11.0-beta.300", + "@xterm/addon-progress": "^0.3.0-beta.300", + "@xterm/addon-search": "^0.17.0-beta.300", + "@xterm/addon-serialize": "^0.15.0-beta.300", + "@xterm/addon-unicode11": "^0.10.0-beta.300", + "@xterm/addon-webgl": "^0.20.0-beta.299", + "@xterm/xterm": "^6.1.0-beta.303", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.4.3", From 7c90a55132121b7aa4f0ea0cea36c3cfd09ebac3 Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:21:09 -0700 Subject: [PATCH 20/21] Managed settings: reuse cached policy more often (#333697) * managed settings: reuse cached policy on reload instead of refetching When forceRemoteSettingsRefresh is not effective, a fresh cached managed-settings response now satisfies startup without a network round-trip, matching the sibling entitlements/token/MCP fetches which only refetch once the cache goes stale. Previously a per-process gate (hasFetchedThisProcess) forced one /managed_settings request on every reload even with a fresh cache and no policy requirement, which for a user without an enterprise policy fanned out into one 404 per GitHub session. Cache reuse is scoped: a cached response is only reused when it was captured for the current account, authentication provider, and endpoint (a legacy cache with no recorded scope is trusted), so a previous GitHub Enterprise host's policy is not applied after switching scopes. An outstanding client_update_required compatibility error is never served from cache: it is revalidated on the next refresh so the fail-closed block clears once the client or server recovers, instead of lingering for the full cache lifetime. The fail-closed path is otherwise unchanged: when the control is effective, only a fresh successful response for the current account, provider, and endpoint clears the requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Trigger signed commit verification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * managed settings: scope failure fallbacks Do not retain or relabel managed settings cached for a different account provider or endpoint when refresh fails or automatic retries are blocked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../accounts/browser/defaultAccount.ts | 33 ++-- .../test/browser/defaultAccount.test.ts | 172 +++++++++++++++++- 2 files changed, 181 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 8e9589e88f8aa0..36b9667d6b400b 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -341,7 +341,6 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private readonly initPromise: Promise; private readonly updateThrottler = this._register(new ThrottledDelayer(100)); private readonly accountDataPollScheduler = this._register(new RunOnceScheduler(() => this.refetchDefaultAccount(), ACCOUNT_DATA_POLL_INTERVAL_MS)); - private readonly managedSettingsFetchAttemptedAccounts = new Set(); private readonly failedManagedSettingsFreshness = new Map(); constructor( @@ -1147,6 +1146,13 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun } const scope = this.createManagedSettingsFreshnessScope(accountId, authenticationProvider.id, managedSettingsUrl); + const cachedScope = accountPolicyData?.managedSettingsScope; + // Only reuse a cache captured for the current provider and endpoint (a legacy cache with no recorded + // scope is trusted), so a previous GitHub Enterprise host's policy is not applied after a scope switch. + const cacheScopeMatches = !cachedScope || this.getManagedSettingsScopeKey(cachedScope) === this.getManagedSettingsScopeKey(scope); + const scopedManagedSettings = cacheScopeMatches ? accountPolicyData?.policyData.managedSettings : undefined; + const scopedManagedSettingsFetchedAt = cacheScopeMatches ? accountPolicyData?.managedSettingsFetchedAt : undefined; + const scopedCachedManagedSettings = cacheScopeMatches ? cachedManagedSettings : undefined; if (requirement.effective && !this.canRequestManagedSettings(options, scope)) { this.logService.debug('[DefaultAccount] Skipping automatic managed settings retry after a prior failure'); const failedFreshness = this.failedManagedSettingsFreshness.get(this.getManagedSettingsScopeKey(scope)); @@ -1154,18 +1160,18 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.setManagedSettingsFreshness({ ...failedFreshness, source: requirement.source }); } return { - data: { managedSettings: accountPolicyData?.policyData.managedSettings }, - fetchedAt: accountPolicyData?.managedSettingsFetchedAt, - scope: accountPolicyData?.managedSettingsScope ?? scope, + data: { managedSettings: scopedManagedSettings }, + fetchedAt: scopedManagedSettingsFetchedAt, + scope, compatibilityError: this._managedSettingsCompatibilityError, }; } - const fetchScopeKey = this.getManagedSettingsScopeKey(scope); - const hasFetchedThisProcess = this.managedSettingsFetchAttemptedAccounts.has(fetchScopeKey); const freshnessSatisfied = requirement.effective && isManagedSettingsFreshnessSatisfiedFor(this._managedSettingsFreshness, scope); - if (!options?.forceRefresh && cachedManagedSettings && ((hasFetchedThisProcess && !requirement.effective) || freshnessSatisfied)) { + // 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) { this.logService.debug('[DefaultAccount] Using last fetched managed settings data'); - return { ...cachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; + return { ...scopedCachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; } const lastAttemptAt = Date.now(); @@ -1177,7 +1183,6 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun lastAttemptAt, }); } - this.managedSettingsFetchAttemptedAccounts.add(fetchScopeKey); const sharedBackoffActive = Date.now() < this._rateLimitBackoffUntil; const result = await this.requestManagedSettings(requirement.effective ? [sessions[0]] : sessions, managedSettingsUrl); if (requirement.effective && !sharedBackoffActive) { @@ -1205,8 +1210,8 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun }); } return { - data: requirement.effective ? { managedSettings: accountPolicyData?.policyData.managedSettings } : { managedSettings: undefined }, - fetchedAt: requirement.effective ? accountPolicyData?.managedSettingsFetchedAt : Date.now(), + data: requirement.effective ? { managedSettings: scopedManagedSettings } : { managedSettings: undefined }, + fetchedAt: requirement.effective ? scopedManagedSettingsFetchedAt : Date.now(), scope, compatibilityError: result.error, }; @@ -1217,14 +1222,14 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun if (requirement.effective) { this.setManagedSettingsFreshness(this.toBlockedManagedSettingsFreshness(requirement.source, result, lastAttemptAt, scope)); return { - data: { managedSettings: accountPolicyData?.policyData.managedSettings }, - fetchedAt: accountPolicyData?.managedSettingsFetchedAt, + data: { managedSettings: scopedManagedSettings }, + fetchedAt: scopedManagedSettingsFetchedAt, scope, compatibilityError: this._managedSettingsCompatibilityError, }; } // A failed fetch must not extend the life of the cached response: carry the cache's timestamp for expiry - const retained = this._managedSettingsCompatibilityError ? undefined : cachedManagedSettings; + const retained = this._managedSettingsCompatibilityError ? undefined : scopedCachedManagedSettings; return { data: { managedSettings: retained?.data.managedSettings }, fetchedAt: retained?.fetchedAt, 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 af5d719b5ba990..e1b3af95aadd65 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -68,7 +68,7 @@ suite('DefaultAccountProvider', () => { }); }); - test('settings without a refresh requirement use the cache after one process fetch', async () => { + test('settings without a refresh requirement use the cache without fetching', async () => { const requestService = new TestRequestService(async () => jsonResponse({ permissions: { disableBypassPermissionsMode: 'disable' }, })); @@ -83,12 +83,35 @@ suite('DefaultAccountProvider', () => { first: first.data, second: second.data, }, { - requestCount: 1, - first: { managedSettings: { 'permissions.disableBypassPermissionsMode': 'disable' } }, + requestCount: 0, + first: cachedPolicy.policyData, second: cachedPolicy.policyData, }); }); + test('forceRefresh fetches fresh even when the cache is fresh, without it the cache is honored', async () => { + const requestService = new TestRequestService(async () => jsonResponse({ + permissions: { disableBypassPermissionsMode: 'disable' }, + })); + const provider = await createProvider(requestService); + const cachedPolicy = createCachedPolicy(false); + + // Without forceRefresh the fresh cache is served with no network round-trip. + const cached = await provider['getManagedSettings'](sessions, cachedPolicy); + // The forceRefresh command bypasses the fresh cache and fetches. + const forced = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true, retryManagedSettings: true }); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + cached: cached.data, + forced: forced.data, + }, { + requestCount: 1, + cached: cachedPolicy.policyData, + forced: { managedSettings: { 'permissions.disableBypassPermissionsMode': 'disable' } }, + }); + }); + 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); @@ -101,7 +124,136 @@ suite('DefaultAccountProvider', () => { managedSettingsFetchedAt: Date.now() - 60 * 60 * 1000, }); - assert.strictEqual(requestService.requestCount, 2); + assert.strictEqual(requestService.requestCount, 1); + }); + + test('outstanding compatibility error revalidates instead of serving a fresh cache', async () => { + const requestService = new TestRequestService(async () => jsonResponse({})); + const provider = await createProvider(requestService); + provider['setManagedSettingsCompatibilityError']({ errorCode: 'client_update_required' }); + const cachedPolicy = createCachedPolicy(false); + + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + compatibilityError: result.compatibilityError, + }, { + requestCount: 1, + compatibilityError: null, + }); + }); + + test('a fresh cache from a different scope is not reused', async () => { + const requestService = new TestRequestService(async () => jsonResponse({ + permissions: { disableBypassPermissionsMode: 'disable' }, + })); + const provider = await createProvider(requestService); + const cachedPolicy = { + ...createCachedPolicy(false), + managedSettingsScope: { + accountId, + authenticationProviderId: 'github-enterprise', + endpointOrigin: 'https://api.ghe.example.com', + }, + }; + + // The cache was captured for a different provider/endpoint than the current github/api.github.com + // scope, so it must be revalidated rather than served for the rest of the cache lifetime. + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + data: result.data, + }, { + requestCount: 1, + data: { managedSettings: { 'permissions.disableBypassPermissionsMode': 'disable' } }, + }); + }); + + test('a fresh cache from a different scope is not retained when refetch fails', async () => { + const requestService = new TestRequestService(async () => { + throw new Error('managed settings unavailable'); + }); + const provider = await createProvider(requestService); + const cachedPolicy = { + ...createCachedPolicy(false), + managedSettingsScope: { + accountId, + authenticationProviderId: 'github-enterprise', + endpointOrigin: 'https://api.ghe.example.com', + }, + }; + + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + data: result.data, + fetchedAt: result.fetchedAt, + scope: result.scope, + }, { + requestCount: 1, + data: { managedSettings: undefined }, + fetchedAt: undefined, + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + }); + }); + + test('a cache from a different scope is not retained by forced refresh failure or retry blocking', async () => { + const requestService = new TestRequestService(async () => { + throw new Error('managed settings unavailable'); + }); + const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + const cachedPolicy = { + ...createCachedPolicy(false), + managedSettingsScope: { + accountId, + authenticationProviderId: 'github-enterprise', + endpointOrigin: 'https://api.ghe.example.com', + }, + }; + + const failed = await provider['getManagedSettings'](sessions, cachedPolicy); + const blocked = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + failed: { + data: failed.data, + fetchedAt: failed.fetchedAt, + scope: failed.scope, + }, + blocked: { + data: blocked.data, + fetchedAt: blocked.fetchedAt, + scope: blocked.scope, + }, + }, { + requestCount: 1, + failed: { + data: { managedSettings: undefined }, + fetchedAt: undefined, + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + }, + blocked: { + data: { managedSettings: undefined }, + fetchedAt: undefined, + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + }, + }); }); test('fresh 404 clears a cached server requirement', async () => { @@ -155,7 +307,7 @@ suite('DefaultAccountProvider', () => { const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - const result = await provider['getManagedSettings'](sessions, cachedPolicy); + const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.deepStrictEqual({ status: provider.managedSettingsFetchStatus, @@ -176,7 +328,7 @@ suite('DefaultAccountProvider', () => { const requestService = new TestRequestService(async () => jsonResponse({ error_code: 'unexpected' }, 466)); const provider = await createProvider(requestService); - const result = await provider['getManagedSettings'](sessions, createCachedPolicy(false)); + const result = await provider['getManagedSettings'](sessions, createCachedPolicy(false), { forceRefresh: true }); assert.deepStrictEqual({ data: result.data, @@ -212,14 +364,14 @@ suite('DefaultAccountProvider', () => { }); }); - test('failed startup fetch retains cached managed settings when no rejection is known', async () => { + test('failed forced refresh retains cached managed settings when no rejection is known', async () => { const requestService = new TestRequestService(async () => { throw new Error('managed settings unavailable'); }); const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - const result = await provider['getManagedSettings'](sessions, cachedPolicy); + const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.deepStrictEqual({ requestCount: requestService.requestCount, @@ -325,7 +477,7 @@ suite('DefaultAccountProvider', () => { const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - await provider['getManagedSettings'](sessions, cachedPolicy); + await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.strictEqual(requestService.requestCount, 2); @@ -732,7 +884,7 @@ suite('DefaultAccountProvider', () => { const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(false); - await provider['getManagedSettings'](sessions, cachedPolicy); + await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); assert.deepStrictEqual({ From c652b837820e004329107c5ef7119b730384e715 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:47:35 -0700 Subject: [PATCH 21/21] fix tool call streaming mismatch when adjusted font-size (#333705) --- .../widget/chatContentParts/media/chatThinkingContent.css | 2 +- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index da2590b85fb0eb..291d98931c558b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -27,7 +27,7 @@ display: inline-flex; align-items: center; line-height: 1.5em; - font-size: 13px; + font-size: var(--vscode-chat-font-size-body-m); &.chat-thinking-title-with-diff { padding-right: var(--vscode-spacing-size20); 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 40c0bfef5c1efd..b7bcf2da95e27d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3217,7 +3217,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-used-context-label { - font-size: var(--vscode-fontSize-body1); + font-size: var(--vscode-chat-font-size-body-m); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); user-select: none; @@ -3340,7 +3340,7 @@ have to be updated for changes to the rules above, or to support more deeply nes align-items: flex-start; gap: 4px; margin: 0 0 var(--vscode-spacing-size160) 0; - font-size: var(--vscode-fontSize-body1); + font-size: var(--vscode-chat-font-size-body-m); /* Tool calls transition from a progress to a collapsible list part, which needs to have this top padding. The working progress also can be replaced by a tool progress part. So align this padding so the text doesn't appear to shift. */